Files
AgentLab/backend/app/tools/custom.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

152 lines
5.3 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 __future__ import annotations
import json
from typing import Any
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field, create_model
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import CustomTool
from app.engine.expressions import render_deep, render_template
from app.sandbox.base import SandboxLimits
from app.sandbox.manager import sandbox_manager
from app.tools.net import UnsafeUrlError, safe_request
from app.tools.registry import ToolContext
_TYPES = {
"string": str,
"number": float,
"integer": int,
"boolean": bool,
"array": list,
"object": dict,
}
def schema_to_model(name: str, schema: dict[str, Any]) -> type[BaseModel]:
"""把 JSON Schema 转成 pydantic 模型,好让它能绑成 LangChain 工具。"""
props = (schema or {}).get("properties") or {}
required = set((schema or {}).get("required") or [])
fields: dict[str, Any] = {}
for key, spec in props.items():
py_type = _TYPES.get(spec.get("type", "string"), str)
description = spec.get("description", "")
if key in required:
fields[key] = (py_type, Field(description=description))
else:
fields[key] = (py_type | None, Field(default=spec.get("default"), description=description))
if not fields:
fields["input"] = (str, Field(default="", description="工具输入"))
return create_model(f"{name}_Args", **fields) # type: ignore[call-overload]
async def _run_http(row: CustomTool, args: dict[str, Any]) -> Any:
"""HTTP 模板工具:url / headers / body 里可以用 {{ 参数名 }} 插值。"""
config = row.config or {}
ctx = dict(args)
url = render_template(config.get("url", ""), ctx)
headers = render_deep(config.get("headers") or {}, ctx)
body = config.get("body")
json_body = None
if body:
rendered = render_deep(body, ctx) if isinstance(body, dict) else render_template(str(body), ctx)
json_body = rendered if isinstance(rendered, dict) else json.loads(rendered or "null")
try:
result = await safe_request(
config.get("method", "GET"),
url,
headers={k: str(v) for k, v in (headers or {}).items()},
json_body=json_body,
timeout=config.get("timeout"),
)
except UnsafeUrlError as e:
return {"error": str(e)}
except json.JSONDecodeError as e:
return {"error": f"请求体不是合法 JSON:{e}"}
text = str(result.get("body", ""))
if config.get("parse_json"):
try:
return {"status": result["status"], "data": json.loads(text)}
except json.JSONDecodeError:
return {"status": result["status"], "body": text}
return {"status": result["status"], "body": text}
async def _run_python(row: CustomTool, args: dict[str, Any], ctx: ToolContext) -> Any:
"""Python 工具:用户代码跑在沙箱里,参数通过 args 变量注入。"""
config = row.config or {}
code = config.get("code", "")
preamble = (
"import json, sys\n"
f"args = json.loads({json.dumps(json.dumps(args, ensure_ascii=False))})\n"
)
result = await sandbox_manager.run(
preamble + code,
language="python",
limits=SandboxLimits(timeout=int(config.get("timeout") or 30)),
session_id=ctx.sandbox_session,
)
if not result.ok:
return {"error": result.error or result.stderr or "执行失败", "exit_code": result.exit_code}
out = result.stdout.strip()
try:
return json.loads(out)
except json.JSONDecodeError:
return out
async def build_custom_tools(
names: list[str], ctx: ToolContext, session: AsyncSession
) -> list[BaseTool]:
rows = list(
(
await session.execute(
select(CustomTool).where(
CustomTool.name.in_(names), CustomTool.enabled.is_(True)
)
)
).scalars()
)
tools: list[BaseTool] = []
for row in rows:
args_model = schema_to_model(row.name, row.parameters or {})
def _make(bound_row: CustomTool):
async def _run(**kwargs: Any) -> str:
if bound_row.kind == "python":
value = await _run_python(bound_row, kwargs, ctx)
else:
value = await _run_http(bound_row, kwargs)
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False, default=str, indent=2)
return _run
tools.append(
StructuredTool(
name=row.name,
description=row.description or f"自定义工具 {row.name}",
args_schema=args_model,
coroutine=_make(row),
func=None,
)
)
return tools
async def run_custom_tool(
session: AsyncSession, name: str, args: dict[str, Any], ctx: ToolContext
) -> Any:
row = (
await session.execute(select(CustomTool).where(CustomTool.name == name))
).scalar_one_or_none()
if not row:
raise KeyError(f"找不到自定义工具 {name!r}")
if row.kind == "python":
return await _run_python(row, args, ctx)
return await _run_http(row, args)