mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:52:01 +00:00
964eeaa829
在画布上拖拽编排 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 个内置模板全部冒烟通过,含真实模型端到端验证。
152 lines
5.3 KiB
Python
152 lines
5.3 KiB
Python
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)
|