mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:51:58 +00:00
467 lines
20 KiB
Python
467 lines
20 KiB
Python
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
|