Files
AgentLab/backend/app/tools/registry.py
T
ThearchyHelios f144ef16d2 独立语义指标库与文档一键拆解:支持多表参数化 SQL 模板、后台进度与 Oracle 方言
- 新增独立语义指标模型(SemanticMetric)与 metric_query 内置工具,同时支持单表聚合表达式(expression)与跨表 JOIN / CTE 参数化模板(sql_template)
- 新增后台口径文档一键拆解任务(SemanticDecomposeJob),支持实时阶段进度、转后台执行、中途取消,串联知识库红线归档、盲列数据目录补丁、指标试算与标准实体绑定四层资产一键落库
- 完善多数据库方言确定性编译(重点补齐 Oracle 顶层 WITH + ROWNUM 限流、标识符引号规整、DATE 字面量与 TO_CHAR 时间粒度转换)
2026-11-12 12:58:24 +08:00

500 lines
23 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 inspect
import json
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Awaitable, Callable
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, ValidationError
ToolFunc = Callable[..., Awaitable[Any]]
class Versions(Enum):
"""ToolContext.data_versions 除了运行固定的那个 dict 之外的两个取值。"""
#: 不在运行里(工具库试用、试用前的副作用检查):用数据源的当前版本。这类调用要显式传它
CURRENT = "current"
#: 缺省值:构造 ToolContext 的地方没说用哪一版。在运行里漏传了固定的版本就是这样——上传源遇到它
#: 报错,不退回当前版本:静默改用当前版本,同一次运行就可能前后查到两版数据而没人察觉
UNSET = "unset"
@dataclass
class ToolContext:
"""一次调用里工具能用到的运行期依赖。"""
run_id: str = ""
node_id: str = ""
sandbox_session: str = "default"
memory_scope: str = "default"
collection: str = "default"
#: 往运行的事件流里发一条事件:节点传 NodeContext.emit(同步);协程函数也认,调用方 await 它的返回值
emit: Callable[..., Any] | None = None
#: 用哪一版上传表格。运行里是固定的版本(runs.data_versions:{源 id: {"snapshot": 快照 id, "name": 源名}}),
#: 由构造 ToolContext 的节点从运行上下文取来(tools/datasource.py 的 run_versions);同一段执行里各节点
#: 拿到的是同一个 dict,运行中途第一次用到的源固定下来后,后面的节点都看得到。
#: None 是升级前发起的运行,用当前版本;Versions.CURRENT 是不在运行里的调用,用当前版本;
#: 缺省 Versions.UNSET 是谁都没给——上传源遇到它报错,见 Versions
data_versions: dict[str, Any] | Versions | None = Versions.UNSET
#: 数据源查询结果交给模型、落进快照之前先遮(tools/datasource.py 的 run_masks_results):开登录后 user 角色发起的
#: 问数据运行,对它来说遮罩就是边界,事件、回答里只能有遮过的值
mask_results: bool = False
#: 数据源查询一律只读,可写源上的写语句也拒(tools/datasource.py 的 run_reads_only):同样是 user 角色的问数据运行。
#: 不能交给审批去拦——Agent 的 approval 是发起人自己写在图里的
force_readonly: bool = False
#: 记忆范围、知识库由运行定,工具参数改不了(tools/datasource.py 的 run_scope_fixed:遮罩、只读的运行——使用者的问数据、
#: 个人应用的刷新、个人蓝图的试运行)。记忆、知识库的内容不经遮罩:kb_search 的 collection 参数(调用工具节点写的、
#: 参数名纠错改出来的、图级 defaults 补上的、模型挑的)一律拒,只能检索 collection 这一个
fixed_scope: bool = False
#: 读哪一份数据目录(data/catalog_view.CatalogView):运行里由节点从 Run.options 取来(catalog_view.of_run),db_schema
#: 给模型看的、查询冻结进表结构快照的、查询时的 SQL 检查都按它。None 是当前目录(缺省,行为同以前)
catalog_view: Any = None
#: 这次运行的类别(formal / exploratory,engine/runner.py 从 Run.run_class 放进 RunContext.extra)。K10 的
#: caliber_query 按它选用途:正式父 Run 走 formal judge,别的(交互、画布)走 compose、拿不到正式身份。None 按
#: exploratory 算——宁可保守,也不把一次探索说成正式的
run_class: str | None = None
#: 这次运行的发起人(服务端身份,同上)。caliber_query 拿它当执行的认证主体,不从工具参数里读
started_by: str | None = None
#: 这次运行发起时定下的选项快照(Run.options,RunContext.extra 里那一份):caliber_query 照原样带进派生的子运行
#: (遮罩、只读、数据目录视图……),不从工具参数里读
run_options: dict[str, Any] = field(default_factory=dict)
#: 这次运行的来源(Run.origin,同上):caliber_query 把它当 trace 带下去,父子关系一眼看得全
origin: dict[str, Any] | None = None
extra: dict[str, Any] = field(default_factory=dict)
#: RunContext.extra 里的键(engine/runner.py 放、构造 ToolContext 的节点取):这次运行的类别、发起人与来源。
#: 用途/主体/父 Run 关系由发起时定下,工具参数改不了(K10)
RUN_CLASS_KEY = "run_class"
RUN_STARTED_BY_KEY = "started_by"
RUN_ORIGIN_KEY = "origin"
def execution_id_of(node_id: str, *, attempt: int | None = None, call_id: str | None = None,
run_id: str | None = None) -> str:
"""一次**实际执行**的执行身份(K10):`<运行>:<节点>#<调用 id 或执行序号>`。
同一次运行里同一条 SQL 执行两次(agent 的两步、失败后接着跑)必须拿到不同的 execution_id——按调用 id(agent 的那
一步)或这个节点的执行序号(调用工具节点的第几次)区分。带上运行 id:口径查询派生的子运行每次调用都是一次新运行,
同一个子节点在自己的运行里都是第 1 次——两次调用交回的工件来自两个子运行,执行身份也必须不同。
query_id 是逻辑查询身份(K08 闭包那一条),execution_id 是这一次执行,两者不能互相当。"""
slot = str(call_id) if call_id else f"e{int(attempt or 1)}"
return f"{run_id}:{node_id}#{slot}" if run_id else f"{node_id}#{slot}"
@dataclass(frozen=True)
class ToolGuide:
"""绑了这个工具时一并交给模型的方法说明(写进 system,engine/nodes/llm.py 的 _build_messages、协作成员同样)。
几个工具共用一份说明时写同一个 id:同一份只注入一次(guides_of 按 id 去重)。说明随代码升版,不是 Skill——Skill 是库里
用户可改、可停用的行,种子只在同名不存在时插入(seed.py),平台的方法要跟着代码走(分析方法,D12)。"""
id: str
text: str
@dataclass
class ToolSpec:
name: str
description: str
category: str
args_schema: type[BaseModel]
func: ToolFunc
# 需要人工确认才执行(写文件、跑代码、发请求这类有副作用的)
dangerous: bool = False
# 函数第一个参数是否接收 ToolContext
needs_context: bool = True
# 改平台自身的状态(写记忆):不危险到默认要人确认,但也不是只读——能力登记里不能记成只读(只读的才能正式运行免审批、
# 才能当写入的查询能力),见 capabilities.registry._discover
writes_state: bool = False
# 能力登记里的效果,写了就照它登记(平台内置的写入能力记 write:只能放在调用工具节点里,经动作流程执行,
# capabilities/platform.py)。不写的按 dangerous / writes_state 推
effect: str | None = None
# 绑了它时一并交给模型的方法说明(ToolGuide)。建工具时放进工具的 metadata["guide"],节点按 guides_of 去重注入
guide: ToolGuide | None = None
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,
writes_state: bool = False,
effect: str | None = None,
guide: ToolGuide | None = None,
) -> 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,
writes_state=writes_state,
effect=effect,
guide=guide,
)
return func
return deco
def all_specs() -> dict[str, ToolSpec]:
import app.tools.builtin # noqa: F401 触发内置工具注册
import app.ledger.querycore # noqa: F401 触发 caliber_query(K10 的父 Run 工具入口)注册
import app.tools.ontology # noqa: F401 触发 ontology_neighbors / source_health 注册
import app.tools.metrics # noqa: F401 触发 metric_query(独立语义指标查询)注册
return dict(_REGISTRY)
def get_spec(name: str) -> ToolSpec | None:
return all_specs().get(name)
def call_is_dangerous(tool: BaseTool | None, name: str, args: dict[str, Any]) -> bool:
"""这一次调用要不要人工确认。所有审批关卡都问这一个函数。
内置工具危不危险是固定的(ToolSpec.dangerous)。动态工具要看这一次传了
什么:同一个 db_query__<源>,SELECT 不用确认,可写源上的 DELETE 必须确认。
它们把判定挂在工具的 metadata["dangerous_if"] 上。以前没有这一层,数据源
工具查不到 ToolSpec,于是在任何审批模式下都被当成安全的。
"""
spec = get_spec(name)
if spec is not None:
return spec.dangerous
judge = ((tool.metadata or {}) if tool is not None else {}).get("dangerous_if")
return bool(judge and judge(args))
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,
# 方法说明跟着工具走:节点按绑定的工具取(guides_of),没有说明的工具 metadata 和以前一样是空的
**({"metadata": {"guide": spec.guide}} if spec.guide is not None else {}),
)
def guides_of(tools: Any) -> list[ToolGuide]:
"""这批工具带的方法说明,按 id 去重、按第一次出现的先后。没有说明的工具不算。"""
out: dict[str, ToolGuide] = {}
for tool in tools or []:
guide = ((getattr(tool, "metadata", None) or {}) if tool is not None else {}).get("guide")
if isinstance(guide, ToolGuide) and guide.id not in out:
out[guide.id] = guide
return list(out.values())
def guides_text(tools: Any) -> str:
"""注入 system 的那一段:每份说明一段,空行隔开。没有说明为空串。"""
return "\n\n".join(g.text.strip() for g in guides_of(tools) if g.text.strip())
async def build_tools(
names: list[str], ctx: ToolContext, *, session: Any = None, allow_write: bool = False
) -> list[BaseTool]:
"""按名字列表组装工具。
allow_write(应用平台 A5):写入能力只能放在调用工具节点里——只有调用工具节点传 True;agent、协作节点用默认值,
能力登记里治理后效果为 write 的工具在这里就被拒绝(ToolBuildError,绑了它的节点失败并说清去哪里改)。发布检查
另有一道 action.write_in_agent(engine/lint_capabilities.py),这里是运行时的那一道:草稿、探索运行不过发布检查。
视参数而定(dynamic)的照旧放行,每次调用按参数问审批(可写数据源的 DML、有副作用的内置工具);绑定端点、由平台
执行的除外,见 capabilities.registry.writes_externally。
四种来源:内置(静态注册)、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, force_readonly=ctx.force_readonly,
mask_results=ctx.mask_results))
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))
if not allow_write and tools:
await _refuse_writes(tools, session)
return tools
async def _refuse_writes(tools: list[BaseTool], session: Any) -> None:
"""写外部系统的工具(capabilities.registry.writes_externally:治理后效果为 write,或绑定端点、视参数而定)不交给
agent。按能力登记的名字认(MCP 是 mcp:<服务>/<工具>,和 trust_key 一致)。"""
from sqlalchemy import select
from app.capabilities.registry import effective_effect, writes_externally
from app.db.models import Capability
keys = {(t.metadata or {}).get("trust_key") or t.name for t in tools}
async def lookup(s: Any) -> list[Any]:
return list((await s.execute(select(Capability).where(Capability.name.in_(list(keys))))).scalars())
if session is not None:
rows = await lookup(session)
else:
from app.db.base import SessionLocal
async with SessionLocal() as own:
rows = await lookup(own)
if writes := [row.name for row in rows if writes_externally(row.name, effective_effect(row), row.endpoint_id)]:
raise ToolBuildError(
f"{'、'.join(f'「{w}」' for w in writes)}是写入能力,只能放在「调用工具」节点里,由动作确认后发送;"
"请从 Agent 的工具清单中移除,改用「调用工具」节点")
class ToolBuildError(ValueError):
"""绑定的工具建不出来:它自己的配置写坏了(比如自定义工具的参数定义)。
message 直接给人看:是哪个工具、坏在哪、去哪里改。节点据此报一句清楚的错,
而不是让 schema 转换在半路炸出一句「'str' object has no attribute 'get'」。
"""
class ToolArgsError(ValueError):
"""工具参数对不上 schema。
message 是直接给人和模型看的:报错里必须说清楚"收到了什么"和"它接受什么",
因为看到这句话的下一步动作就是照着改参数名。不要再往外包一层。
"""
_TYPE_LABELS = {
"string": "字符串", "integer": "整数", "number": "数字",
"boolean": "布尔", "array": "数组", "object": "对象",
}
def _type_label(prop: dict[str, Any]) -> str:
"""JSON Schema 的类型写成人话。可选字段是 anyOf[T, null],要摊开看。"""
names: list[str] = []
for node in [prop, *(prop.get("anyOf") or [])]:
kind = node.get("type")
if isinstance(kind, str) and kind != "null":
names.append(_TYPE_LABELS.get(kind, kind))
return "/".join(dict.fromkeys(names)) or "任意"
def args_model_of(tool: BaseTool) -> type[BaseModel] | None:
"""工具的参数模型。
MCP 这类动态工具的 args_schema 可能直接是一份 JSON Schema dict,拿不到
pydantic 模型——那种只能原样放行,不能因为校验不了就拒绝调用。
"""
schema = getattr(tool, "args_schema", None)
if isinstance(schema, type) and issubclass(schema, BaseModel):
return schema
return None
def describe_args(
schema: type[BaseModel],
got: dict[str, Any] | None = None,
error: ValidationError | None = None,
) -> str:
"""把"哪里不对、它到底要什么"写成一句能照着改的话。
字段说明取 Field(description=...),各工具定义里本来就写了,这里只是搬到报错里
——模型拿到这句话就能在下一步自己改对,不用人工介入。
返回的永远是一句完整的话("参数有误:…。可用参数:…"),调用方直接拼在
工具名后面即可,不用猜这次会不会缺个主语。
"""
js = schema.model_json_schema()
props: dict[str, Any] = js.get("properties") or {}
required = [k for k in (js.get("required") or []) if k in props]
fields: list[str] = []
for key, prop in props.items():
tag = "必填" if key in required else "可选"
desc = str(prop.get("description") or "").strip()
fields.append(f"{key}({tag},{_type_label(prop)})" + (f" — {desc}" if desc else ""))
tail = "可用参数:" + ";".join(fields) if fields else "该工具不接受任何参数。"
got = dict(got or {})
unknown = [k for k in got if k not in props]
missing = [k for k in required if k not in got]
problems: list[str] = []
if unknown:
problems.append(f"{'、'.join(unknown)} 不是该工具的参数")
if missing:
problems.append(f"缺少必填参数 {'、'.join(missing)}")
for err in error.errors() if error else ():
loc = ".".join(str(p) for p in (err.get("loc") or ()))
if not loc or loc in unknown or loc in missing:
continue # 上面两条已经说过了,不重复
# 用 schema 里的类型说,不要把 pydantic 那句英文原样甩出去
problems.append(
f"{loc} 的值不合法,应为{_type_label(props[loc])}"
if loc in props
else f"{loc} 的值不合法({err.get('msg', '')})"
)
head = "参数有误:" + ";".join(problems) + "。" if problems else "参数有误。"
return f"{head}{tail}"
def prepare_args(
schema: type[BaseModel], args: dict[str, Any]
) -> tuple[dict[str, Any], str | None]:
"""校验工具参数,必要时纠正一个明显写错的参数名。
返回 (可直接展开给函数的 payload, 纠正说明或 None)。纠正说明不为空时调用方
**必须**把它说出去——参数还错在配置里/还错在模型脑子里,替它跑通一次却不吭声,
下一次照样踩。
多余的参数名在这里是硬错误,哪怕校验能过:pydantic 默认把认不出的键悄悄丢掉,
于是全可选字段的 schema 上 {"tabel": "orders"} 会"成功"执行一次什么都没查的调用。
这种静默失败比报错难查得多。
纠正只在候选唯一时发生。多于一个就有排列组合,猜对了也是运气,不如把 schema
摊开让人或模型自己改。
"""
args = dict(args or {})
js = schema.model_json_schema()
props: dict[str, Any] = js.get("properties") or {}
required = [k for k in (js.get("required") or []) if k in props]
unknown = [k for k in args if k not in props]
if not unknown:
try:
return schema(**args).model_dump(), None
except ValidationError as e:
raise ToolArgsError(describe_args(schema, args, e)) from e
# 少了哪个必填的,就是多出来那个想写的;一个必填都不缺时,退而看还空着哪个可选字段
missing = [k for k in required if k not in args] or [k for k in props if k not in args]
if len(unknown) == 1 and len(missing) > 1:
# 空着的可选字段不止一个(db_schema 有 table、keyword 两个):只有拼写明显接近的那一个才算唯一候选
import difflib
close = [k for k in missing if difflib.SequenceMatcher(None, unknown[0].lower(), k.lower()).ratio() >= 0.8]
if len(close) == 1:
missing = close
residual: ValidationError | None = None
if len(unknown) == 1 and len(missing) == 1:
fixed = dict(args)
fixed[missing[0]] = fixed.pop(unknown[0])
try:
payload = schema(**fixed).model_dump()
except ValidationError as e:
# 改完名还是不合法。把这些毛病一并说出来,省得对方改对了名字
# 再回来撞一次类型——describe_args 会跳过已经报过的那几个字段
residual = e
else:
return payload, f"参数名 {unknown[0]} 不存在,已按唯一候选 {missing[0]} 执行"
raise ToolArgsError(describe_args(schema, args, residual))
async def call_tool(
name: str,
args: dict[str, Any],
ctx: ToolContext,
session: Any = None,
*,
on_fix: Callable[[str], None] | None = None,
) -> Any:
"""直接调用一个工具(工具节点、以及设置页的"试一下"按钮用)。
两条分支都先过 args_schema。动态工具那条以前是裸 `**args` 展开的,参数名写错
时抛的是 TypeError,报错里只剩内部闭包名——`_make_query_tool.<locals>._run() got
an unexpected keyword argument 'query'` 既指不到是哪个工具,也说不出该填什么。
on_fix 在参数名被纠正时回调一次。call_tool 不该知道事件系统长什么样,
所以只把这件事交出去,由调用方决定说给谁听。
"""
spec = get_spec(name)
if spec:
payload, note = prepare_args(spec.args_schema, args)
if note and on_fix:
on_fix(note)
if spec.needs_context:
return await spec.func(ctx, **payload)
return await spec.func(**payload)
# 直接调用(调用工具节点、工具库试用):参数是人配的,不是模型挑的,写入能力也能调——写入在运行里另有动作流程把关
tools = await build_tools([name], ctx, session=session, allow_write=True)
if not tools:
raise KeyError(f"找不到工具 {name}")
tool = tools[0]
model = args_model_of(tool)
if model is None:
payload = dict(args)
else:
payload, note = prepare_args(model, args)
if note and on_fix:
on_fix(note)
if tool.coroutine:
return await tool.coroutine(**payload)
result = tool.invoke(payload)
if inspect.isawaitable(result):
return await result
return result