Files
AgentLab/backend/app/tools/registry.py
T
ThearchyHelios 601b84918d 分析能力入口(二):五个 agent 分析工具(analysis_decompose / baseline / trend / differ / comparable,名字取 contract.TOOL_NAMES),参数只有「算什么」(引用本节点查过的结果 Q3、列的角色、期次、总数 headline、下钻 parent、度量叫法、在验证什么),没有阈值参数;五个工具共用一份「分析方法」说明(ToolSpec 新增 guide:方法说明 + 去重用的 id,register / build_tools 透传进工具的 metadata),Agent 节点、协作成员绑了任一分析工具就接在 Skill 后面注入 system,同一份只注入一次,模型调用节点不注入。Agent 建工具时经 ToolContext.extra 交一个本节点的分析容器(app/tools/analysis.py 的 AnalysisScope),每次查询记台账时往里放一份输入快照(编号和「【证据 Qn】」同一个),分析结论落成 analysis_result 工件、台账记 contract.ledger_entry(带 call_id),交回模型「【分析 An】」加判定语、判定标准、关键数、附带说明、算不了的原因、结果表前几段;tool.end 带 analysis_artifact;下钻按 An 取本节点的结论;调用写错(contract.CallError,含这个节点的分析次数用完 max_calls)作为工具错误交回、不记台账、不占编号;算不了照样记结论并发一条 analysis_refused。编号只在记台账时放进容器,审批恢复重放时分析不再执行、编号不错位。遮罩列按证据面板同一个函数认(数据源设的、按 SQL 认出改了名字的),遮罩运行交给算子的是遮过的快照。阈值读发起时的快照,没有就现读设置;运行期依赖给运行当天、按运行目录视图读的目录、剖析事实、现查表范围的探查(W1-a 的 make_prober,还没有时不给)。协作成员同样注入方法、同样编号(协作节点不记台账)。使用者问数据的白名单加上五个分析工具和分析卡;调用工具节点调分析工具在画布上就报错(analysis.tool_node)。能力登记不把它们当查询(is_platform_query 不认)
2026-10-21 13:02:30 +08:00

467 lines
20 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
extra: dict[str, Any] = field(default_factory=dict)
@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 触发内置工具注册
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