mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:51:59 +00:00
39e7a5b014
阶段①。产品原先能取数的只有 http_request 和沙箱里自己写代码——面对"分析上季度
华东区销量下滑"这类需求,用户得自己知道数据在哪张表、字段叫什么,再手写 SQL 塞进
代码节点。最难的部分留给了用户。
## 连接层(app/data/)
方言差异全收敛在 engine.py 一处:Oracle 要 service_name/sid、MySQL 要 charset、
各家默认端口不同。三个驱动都是 asyncio 原生且免装客户端——oracledb 走 thin 模式,
不需要 Instant Client。密码复用 app/core/crypto 的 Fernet,和 Provider 同一套;
build_url 默认把密码替换成星号,日志和错误信息里用这个版本。
introspect.py 产出两种粒度:summary()(表名+代表字段,几百 token,给 Copilot 的
system prompt 用)和 describe_table()(单表完整字段,按需取)。分两种是因为一个
中等业务库全量塞进 prompt 轻松几万 token,既贵又会把真正的需求淹掉。
## 守卫(app/data/guard.py)
这是唯一不能出错的地方:SQL 由模型生成,模型会写出什么事前无法穷举,防线不能
建立在"它应该不会这么干"上。三层:
- 只读判定用**白名单**而非黑名单——黑名单永远漏,MERGE/CALL/GRANT/各家私货写不完
- 多语句在到达驱动前就断掉,不赌驱动默认只跑第一条(MySQL 开了
CLIENT_MULTI_STATEMENTS 就会全跑)
- 行数/字节/超时三重上限,一条 SELECT * 不该把内存和模型上下文一起打爆
刻意不引 sqlparse:完整解析 SQL 方言是无底洞,而这里的判断很窄——"首关键字是
什么"和"有没有第二条语句"。窄问题用窄解法。难点是不能误杀:`SELECT ';'` 只是
一条语句,简单 split(";") 会把它切两半然后拒掉,那比漏杀更让人无从下手。
## 工具(app/tools/datasource.py)
每个数据源动态生成一对 db_query__<源> / db_schema__<源>,而不是一个通用工具带
datasource 参数:工具名自带语义,模型不会把查销售库的 SQL 发给财务库;description
里直接塞表清单,省掉"先问有哪些表"那一轮。接进 registry.build_tools 作为第四来源
(前三个是内置/MCP/自定义)。
查询结果落 put_json(kind="query_snapshot"),把 SQL 原文 + 结果集 + 数据源一起存,
出具体系的数字回指能下钻到"这个数是哪条 SQL 查出来的"。
## 实测
真实 SQLite 业务库(600 行订单,埋了华东 6 月断崖的信号)+ 真实模型:
agent 8 轮自主查询——列表 → 查三张表结构 → 探数据时间范围 → 查地区维度 →
按月汇总 → 按地区+月份下钻,准确定位到华东 6 月从 1187 件跌到 354 件。
只读守卫在 agent 路径上同样生效(UPDATE/DROP/多语句全被拒且数据未变)。
测试 55 项:守卫 30 项(含"字符串里的分号不能误杀")、连接与探查 15 项、
URL 构造 6 项、Copilot 流式 4 项。
174 lines
5.0 KiB
Python
174 lines
5.0 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>)、数据源
|
||
(db_query__<源名> / db_schema__<源名>)、自定义(数据库里的 CustomTool)。
|
||
后两种都是运行时从库里读出来动态构造的,不在静态注册表里。
|
||
"""
|
||
from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX
|
||
|
||
specs = all_specs()
|
||
tools: list[BaseTool] = []
|
||
mcp_names: list[str] = []
|
||
datasource_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))
|
||
elif name.startswith((QUERY_PREFIX, SCHEMA_PREFIX)):
|
||
datasource_names.append(name)
|
||
else:
|
||
custom_names.append(name)
|
||
|
||
if datasource_names and session is not None:
|
||
from app.tools.datasource import build_datasource_tools
|
||
|
||
tools.extend(await build_datasource_tools(datasource_names, ctx, session))
|
||
|
||
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
|