mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:41:58 +00:00
1a6082f033
为什么:几千张表的库以前用不了——探查每个源最多取 200 个对象、按数据库列出的顺序截断,同步请求、整份替换、不排队; 落在窗口外的表在目录、look、剖析里被说成「表已不在」。 做了什么: - db/models_sources.py:source_objects(对象索引一行一个对象:范围、原因、结构指纹与 struct_version、first_seen / last_seen / removed_at、reflected_at、按需加入的留痕)、introspect_jobs / introspect_tasks(后台探查任务与逐表状态)。 - data/source_objects.py:每种方言一条字典查询建对象索引(失败退回 Inspector,上限 5 万,截断时不判删除);探查范围规则 (options.introspect_scope:通配、按 name_key、排除优先、显式列出 / 移出;有目录的表、已发布模板写死 SQL 用到的表 pinned; 命中超上限拒绝并说出命中数);唯一的 rebuild_schema_cache 从范围内已反射的行物化 schema_cache(形状不变,另加 scope 摘要); 三种「不在」分开说(removed / missing / out_of_scope / reflect_failed);按需加入(当场只读反射、落盘、留痕、进程内按源加锁)。 - data/introspect_jobs.py:探查改成后台任务(core/job_runtime,lane kind introspect,和同源起草、剖析串行;按批反射、逐表记状态、 可停止、读完的照样物化;重启标中断不续跑)。 - api/datasources.py:POST /introspect 返回 202 和任务,dry_run 同步只刷新索引、返回命中数;/introspect-jobs 读任务、停止; /introspect-scope 读、预览、保存规则;/schema?table= 带剖析事实、说清三种「不在」;DataSourceOut 加 schema_scope、introspect_job。 - schema_view:search_tables(BM25 倒排,遮罩列只用列名,范围外只按名字和注释、权重低一档)、profile_lines(剖析事实一节, 附「只供写查询参考」,遮罩按渲染那一刻重滤,上传表格快照不一致时不给)、absent_detail。 - look:候选超过 PREFILTER_LIMIT 时只给预筛结果 + 已用到的表 + 一跳关联;点名或挑中范围外的表按需加入(每次查看最多 4 张), look 记录加 added_to_scope、prefiltered;表详情带剖析事实。 - tools/datasource:db_schema__ 加 keyword、不传 table 时只列前 50 张;点名范围外的表按需加入(每次运行最多 8 张);查询结果 照实列「未按目录检查的表」(unchecked_tables,不新增检查规则);建工具时读剖析事实。 - 剖析:_relation_ends 分清「范围外,可以加入」和「表结构里没有」;任务记上传表格的 snapshot_id;last_facts。表助手、/schema 用同一段剖析事实。 - 目录接口:清单只列范围内,absent_reason 分开说;GET /catalog/search 查对象索引,POST /catalog/scope/add 加入范围。 - 第 1 波留下的一处:check_valid_filter 和 stored_valid_filter 拼有效记录条件时按 quote_name 强制加引号。 - 工具参数纠错:空着的可选参数不止一个时,只认拼写明显接近的那一个(db_schema 多了 keyword 以后 tabel 仍能纠成 table)。 - 测试:3,000 张表的合成库(对象索引、缺省范围超上限拒绝、规则圈 400 张后台读完、停止、重启标中断、和剖析排队、同步起草 409)、删除与结构版本、Inspector 回退、截断不判删除、范围规则、pinned、方言金样;改了同步探查的老测试改为等任务做完。
435 lines
18 KiB
Python
435 lines
18 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
|
||
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
|
||
|
||
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,
|
||
) -> 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,
|
||
)
|
||
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,
|
||
)
|
||
|
||
|
||
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
|