mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:21:58 +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 个内置模板全部冒烟通过,含真实模型端到端验证。
159 lines
4.4 KiB
Python
159 lines
4.4 KiB
Python
from __future__ import annotations
|
||
|
||
import inspect
|
||
import json
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Awaitable, Callable
|
||
|
||
from langchain_core.tools import BaseTool, StructuredTool
|
||
from pydantic import BaseModel
|
||
|
||
ToolFunc = Callable[..., Awaitable[Any]]
|
||
|
||
|
||
@dataclass
|
||
class ToolContext:
|
||
"""一次调用里工具能用到的运行期依赖。"""
|
||
|
||
run_id: str = ""
|
||
node_id: str = ""
|
||
sandbox_session: str = "default"
|
||
memory_scope: str = "default"
|
||
collection: str = "default"
|
||
emit: Callable[..., Awaitable[None]] | None = None
|
||
extra: dict[str, Any] = field(default_factory=dict)
|
||
|
||
|
||
@dataclass
|
||
class ToolSpec:
|
||
name: str
|
||
description: str
|
||
category: str
|
||
args_schema: type[BaseModel]
|
||
func: ToolFunc
|
||
# 需要人工确认才执行(写文件、跑代码、发请求这类有副作用的)
|
||
dangerous: bool = False
|
||
# 函数第一个参数是否接收 ToolContext
|
||
needs_context: bool = True
|
||
|
||
def json_schema(self) -> dict[str, Any]:
|
||
return self.args_schema.model_json_schema()
|
||
|
||
|
||
_REGISTRY: dict[str, ToolSpec] = {}
|
||
|
||
|
||
def register(
|
||
name: str,
|
||
description: str,
|
||
category: str,
|
||
args_schema: type[BaseModel],
|
||
*,
|
||
dangerous: bool = False,
|
||
needs_context: bool = True,
|
||
) -> Callable[[ToolFunc], ToolFunc]:
|
||
def deco(func: ToolFunc) -> ToolFunc:
|
||
_REGISTRY[name] = ToolSpec(
|
||
name=name,
|
||
description=description,
|
||
category=category,
|
||
args_schema=args_schema,
|
||
func=func,
|
||
dangerous=dangerous,
|
||
needs_context=needs_context,
|
||
)
|
||
return func
|
||
|
||
return deco
|
||
|
||
|
||
def all_specs() -> dict[str, ToolSpec]:
|
||
import app.tools.builtin # noqa: F401 触发内置工具注册
|
||
|
||
return dict(_REGISTRY)
|
||
|
||
|
||
def get_spec(name: str) -> ToolSpec | None:
|
||
return all_specs().get(name)
|
||
|
||
|
||
def _stringify(value: Any) -> str:
|
||
"""工具结果最终是喂给模型的,所以统一成字符串;结构化数据转紧凑 JSON。"""
|
||
if isinstance(value, str):
|
||
return value
|
||
if value is None:
|
||
return ""
|
||
return json.dumps(value, ensure_ascii=False, default=str, indent=2)
|
||
|
||
|
||
def build_tool(spec: ToolSpec, ctx: ToolContext) -> BaseTool:
|
||
"""把注册表里的定义 + 运行期上下文,绑成一个 LangChain 工具。"""
|
||
|
||
async def _run(**kwargs: Any) -> str:
|
||
if spec.needs_context:
|
||
result = await spec.func(ctx, **kwargs)
|
||
else:
|
||
result = await spec.func(**kwargs)
|
||
return _stringify(result)
|
||
|
||
return StructuredTool(
|
||
name=spec.name,
|
||
description=spec.description,
|
||
args_schema=spec.args_schema,
|
||
coroutine=_run,
|
||
# 只给异步实现;同步路径直接报错,避免在事件循环里被阻塞调用
|
||
func=None,
|
||
)
|
||
|
||
|
||
async def build_tools(
|
||
names: list[str], ctx: ToolContext, *, session: Any = None
|
||
) -> list[BaseTool]:
|
||
"""按名字列表组装工具。支持三种来源:内置、MCP(mcp:<server>/<tool>)、自定义。"""
|
||
specs = all_specs()
|
||
tools: list[BaseTool] = []
|
||
mcp_names: list[str] = []
|
||
custom_names: list[str] = []
|
||
|
||
for name in names:
|
||
if name.startswith("mcp:"):
|
||
mcp_names.append(name)
|
||
elif name in specs:
|
||
tools.append(build_tool(specs[name], ctx))
|
||
else:
|
||
custom_names.append(name)
|
||
|
||
if mcp_names:
|
||
from app.tools.mcp_manager import mcp_manager
|
||
|
||
tools.extend(await mcp_manager.get_tools(mcp_names))
|
||
|
||
if custom_names and session is not None:
|
||
from app.tools.custom import build_custom_tools
|
||
|
||
tools.extend(await build_custom_tools(custom_names, ctx, session))
|
||
|
||
return tools
|
||
|
||
|
||
async def call_tool(name: str, args: dict[str, Any], ctx: ToolContext, session: Any = None) -> Any:
|
||
"""直接调用一个工具(工具节点、以及设置页的"试一下"按钮用)。"""
|
||
spec = get_spec(name)
|
||
if spec:
|
||
validated = spec.args_schema(**args)
|
||
payload = validated.model_dump()
|
||
if spec.needs_context:
|
||
return await spec.func(ctx, **payload)
|
||
return await spec.func(**payload)
|
||
|
||
tools = await build_tools([name], ctx, session=session)
|
||
if not tools:
|
||
raise KeyError(f"找不到工具 {name!r}")
|
||
tool = tools[0]
|
||
if tool.coroutine:
|
||
return await tool.coroutine(**args)
|
||
result = tool.invoke(args)
|
||
if inspect.isawaitable(result):
|
||
return await result
|
||
return result
|