mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:21:59 +00:00
941723b17c
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人 - 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效 - 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释 - 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标 - 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
452 lines
25 KiB
Python
452 lines
25 KiB
Python
"""登记的端点与平台执行器(A6 第 2、3、7 条,§3.9)。
|
||
|
||
端点 = 管理员登记的外部系统:按环境(test / prod)存 base_url、认证头(值加密)、allow_intranet、verify_tls。
|
||
绑定端点的 HTTP 能力(自定义工具 kind=http,config 写 {endpoint, method, path, query, body})不由模型给地址:
|
||
- 地址只能是登记的 base_url 加上路径模板,路径里的参数逐段 URL 编码后代入(/ ? # 都编码掉),模型拿不到任意 URL;
|
||
- 只有登记的端点能开内网访问,开了也照样拦元数据地址和高危端口(tools/net.py);
|
||
- 不跟随跳转:跳到 base_url 之外就不再是登记的那个系统了,3xx 原样交回。
|
||
|
||
环境:正式运行走 prod;探索运行走 test,端点没有 test 时演练(不发送,environment=simulated,Q4)。读取在探索运行里
|
||
没有 test 时走 prod:读不改外部系统,挡掉它只会让问数据用不上这个端点(pick_environment)。
|
||
|
||
声明了 tabular 的读取把响应落成和查询快照同一个形状的工件(kind=endpoint_snapshot):{source, columns, rows, truncated,
|
||
request, ...},交给模型的结果再带上 artifact。台账照 query 处理,报告的单元格标记、口径卡取值、运行对比都不用改。
|
||
外部响应的原文嵌在快照的 response 里,顶层字段全是平台自己拼的,外部系统伪造不了。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
from urllib.parse import quote
|
||
|
||
import httpx
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.capabilities import secrets as vault
|
||
from app.capabilities.registry import EndpointResult, effective_effect
|
||
from app.core.config import settings
|
||
from app.db.models import Capability, CustomTool, Endpoint
|
||
from app.engine.expressions import render_template
|
||
from app.tools.net import UnsafeUrlError, assert_safe_url
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
ENVIRONMENTS = ("test", "prod")
|
||
SNAPSHOT_KIND = "endpoint_snapshot"
|
||
#: 落快照最多收多少行,和数据源查询的缺省行数上限一致
|
||
MAX_ROWS = 1000
|
||
_WHOLE = re.compile(r"^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$")
|
||
|
||
|
||
class EndpointError(ValueError):
|
||
"""端点或绑定它的能力配置不对:给人看的一句话。"""
|
||
|
||
|
||
# ---------------------------------------------------------------- 端点本身
|
||
|
||
|
||
def env_out(env: dict[str, Any] | None) -> dict[str, Any] | None:
|
||
if not env:
|
||
return None
|
||
return {"base_url": env.get("base_url") or "", "headers": vault.masked(env.get("headers")),
|
||
"allow_intranet": bool(env.get("allow_intranet")), "verify_tls": env.get("verify_tls", True) is not False}
|
||
|
||
|
||
def endpoint_out(row: Endpoint) -> dict[str, Any]:
|
||
envs = row.environments or {}
|
||
return {"id": row.id, "name": row.name, "description": row.description or "",
|
||
"environments": {k: env_out(envs.get(k)) for k in ENVIRONMENTS if envs.get(k)},
|
||
"idempotency_header": row.idempotency_header, "timeout_s": row.timeout_s, "version": row.version,
|
||
"updated_by": row.updated_by}
|
||
|
||
|
||
def seal_environments(given: dict[str, Any] | None, previous: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""提交上来的 environments → 存库的样子。只认 test、prod;base_url 必须是 http(s);头的值加密,提交掩码值保持原值。
|
||
只配了测试环境的也收(接入阶段先在测试环境上试):正式运行要用的写入能力由发布检查 action.prod_missing 拦。"""
|
||
before = previous or {}
|
||
out: dict[str, Any] = {}
|
||
for key, env in (given or {}).items():
|
||
if key not in ENVIRONMENTS:
|
||
raise EndpointError(f"环境只能是测试(test)或生产(prod),当前为「{key}」")
|
||
if not env:
|
||
continue
|
||
base = str(env.get("base_url") or "").strip().rstrip("/")
|
||
if not re.match(r"^https?://[^/?#]+", base):
|
||
raise EndpointError(f"{_ENV_LABEL[key]}的地址需要以 http:// 或 https:// 开头")
|
||
if "?" in base or "#" in base:
|
||
raise EndpointError(f"{_ENV_LABEL[key]}的地址不能带查询参数,参数请写在能力的配置里")
|
||
headers = vault.seal(env.get("headers"), (before.get(key) or {}).get("headers"))
|
||
# 按掩码换回原值之后再查:界面提交的是掩码,查的是真正要发出去的那一份
|
||
if problem := vault.header_problem(vault.reveal(headers)):
|
||
raise EndpointError(f"{_ENV_LABEL[key]}的认证头有误:{problem}")
|
||
out[key] = {"base_url": base, "headers": headers, "allow_intranet": bool(env.get("allow_intranet")),
|
||
"verify_tls": env.get("verify_tls", True) is not False}
|
||
if not out:
|
||
raise EndpointError("端点至少需要配置一个环境")
|
||
return out
|
||
|
||
|
||
def idempotency_header_problem(name: str) -> str | None:
|
||
"""幂等请求头的名字合不合法:写入时按它带上幂等键,名字不合法每次写入都发不出去。"""
|
||
problem = vault.header_problem({name: ""})
|
||
return f"幂等请求头有误:{problem}" if problem else None
|
||
|
||
|
||
_ENV_LABEL = {"test": "测试环境", "prod": "生产环境"}
|
||
|
||
|
||
def pick_environment(endpoint: Endpoint, *, formal: bool, effect: str) -> str:
|
||
"""这一次走哪个环境。正式运行 prod;探索运行 test,没有 test 时写入演练、读取走 prod。"""
|
||
if formal:
|
||
return "prod"
|
||
if (endpoint.environments or {}).get("test"):
|
||
return "test"
|
||
return "prod" if effect == "read" else "simulated"
|
||
|
||
|
||
async def find_endpoint(session: AsyncSession, ref: str | None) -> Endpoint | None:
|
||
"""按 id 找,找不到再按名字找:自定义工具的配置里两种写法都认。"""
|
||
if not ref:
|
||
return None
|
||
row = await session.get(Endpoint, ref)
|
||
if row is not None:
|
||
return row
|
||
return (await session.execute(select(Endpoint).where(Endpoint.name == ref))).scalar_one_or_none()
|
||
|
||
|
||
def bound_endpoint_ref(tool: CustomTool) -> str | None:
|
||
"""自定义工具绑定的端点(config.endpoint),没绑定为 None。"""
|
||
if tool.kind != "http":
|
||
return None
|
||
ref = (tool.config or {}).get("endpoint")
|
||
return str(ref) if ref else None
|
||
|
||
|
||
# ---------------------------------------------------------------- 拼请求
|
||
|
||
|
||
def _fill(value: Any, args: dict[str, Any]) -> Any:
|
||
"""请求体、查询参数里的模板。整个值就是一个 {{ 参数 }} 的按参数原样代入(金额还是数),别的按文字渲染。"""
|
||
if isinstance(value, str):
|
||
whole = _WHOLE.match(value.strip())
|
||
if whole:
|
||
return args.get(whole.group(1))
|
||
return render_template(value, args)
|
||
if isinstance(value, dict):
|
||
return {k: _fill(v, args) for k, v in value.items()}
|
||
if isinstance(value, list):
|
||
return [_fill(v, args) for v in value]
|
||
return value
|
||
|
||
|
||
def render_path(template: str, args: dict[str, Any]) -> str:
|
||
"""路径模板逐段代入:每个参数值整段 URL 编码(/ ? # % 都编码掉),模型给的值走不出这一段。. 和 .. 不收:
|
||
它们编码前后一样,拼进去就成了往上走一层。"""
|
||
encoded: dict[str, Any] = {}
|
||
for key, value in args.items():
|
||
text = "" if value is None else str(value)
|
||
if text in (".", ".."):
|
||
raise EndpointError(f"路径参数 {key} 的值不能是「{text}」")
|
||
encoded[key] = quote(text, safe="")
|
||
path = render_template(template or "", encoded)
|
||
return path if not path or path.startswith("/") else f"/{path}"
|
||
|
||
|
||
def build_request(endpoint: Endpoint, environment: str, config: dict[str, Any], args: dict[str, Any]
|
||
) -> dict[str, Any]:
|
||
"""{method, url, path, query, body}。没写 body 的:GET / HEAD / DELETE 不带请求体,其余把参数整个作为 JSON 请求体。"""
|
||
env = (endpoint.environments or {}).get(environment if environment != "simulated" else "test") \
|
||
or (endpoint.environments or {}).get("prod") or {}
|
||
method = str(config.get("method") or "GET").upper()
|
||
path = render_path(str(config.get("path") or ""), args)
|
||
query = {str(k): v for k, v in (_fill(config.get("query") or {}, args) or {}).items()
|
||
if v is not None and v != ""}
|
||
if "body" in config and config.get("body") is not None:
|
||
body = _fill(config["body"], args)
|
||
else:
|
||
body = None if method in ("GET", "HEAD", "DELETE") else dict(args)
|
||
base = str(env.get("base_url") or "").rstrip("/")
|
||
return {"method": method, "url": f"{base}{path}", "path": path, "query": query, "body": body}
|
||
|
||
|
||
# ---------------------------------------------------------------- 表格化
|
||
|
||
|
||
def pointer(doc: Any, ptr: str) -> Any:
|
||
"""JSON 指针(RFC 6901)。取不到为 None。"""
|
||
if ptr in ("", "/"):
|
||
return doc
|
||
cur = doc
|
||
for raw in ptr.lstrip("/").split("/"):
|
||
part = raw.replace("~1", "/").replace("~0", "~")
|
||
if isinstance(cur, dict):
|
||
cur = cur.get(part)
|
||
elif isinstance(cur, list) and part.isdigit() and int(part) < len(cur):
|
||
cur = cur[int(part)]
|
||
else:
|
||
return None
|
||
return cur
|
||
|
||
|
||
def tabulate(body: Any, tabular: dict[str, Any]) -> tuple[list[str], list[list[Any]], bool]:
|
||
"""按 tabular = {rows: 行所在的指针, columns: {列名: 行内指针}} 把响应排成表。"""
|
||
items = pointer(body, str(tabular.get("rows") or ""))
|
||
if not isinstance(items, list):
|
||
raise EndpointError("响应里按「行」的位置找不到列表,请检查能力登记里表格的行位置")
|
||
columns = {str(k): str(v) for k, v in (tabular.get("columns") or {}).items()}
|
||
names = list(columns)
|
||
rows = [[pointer(item, columns[n]) for n in names] for item in items[:MAX_ROWS]]
|
||
return names, rows, len(items) > MAX_ROWS
|
||
|
||
|
||
def masked_columns_of(tabular: dict[str, Any], masked: list[Any]) -> list[str]:
|
||
"""遮罩字段(JSON 指针)落在表格的哪几列:快照里记成 mask_columns,证据面板按它遮,口径同查询快照。"""
|
||
hidden = {str(m) for m in masked or []}
|
||
return [str(name) for name, ptr in (tabular.get("columns") or {}).items() if str(ptr) in hidden]
|
||
|
||
|
||
# ---------------------------------------------------------------- 平台执行器
|
||
|
||
|
||
def _client(**kwargs: Any) -> httpx.AsyncClient:
|
||
"""发请求用的客户端。测试里换成 MockTransport。"""
|
||
return httpx.AsyncClient(**kwargs)
|
||
|
||
|
||
def _parse(resp: httpx.Response) -> Any:
|
||
content = resp.content[: settings.http_tool_max_bytes]
|
||
text = content.decode(resp.encoding or "utf-8", errors="replace")
|
||
try:
|
||
return json.loads(text)
|
||
except ValueError:
|
||
return text
|
||
|
||
|
||
async def _resolve(session: AsyncSession, capability: Any, tool: CustomTool | None = None
|
||
) -> tuple[Capability | None, CustomTool, Endpoint]:
|
||
"""能力 → (登记行, 自定义工具, 端点)。给了 tool 就按它拼请求(工具页试运行的草稿:发的是眼前编辑的配置),没给按能力名
|
||
找已保存的那一行。"""
|
||
name = capability if isinstance(capability, str) else capability.name
|
||
cap = capability if isinstance(capability, Capability) else await session.get(Capability, name)
|
||
if tool is None:
|
||
tool = (await session.execute(select(CustomTool).where(CustomTool.name == name))).scalar_one_or_none()
|
||
if tool is None or bound_endpoint_ref(tool) is None:
|
||
raise EndpointError(f"能力「{name}」没有绑定登记的端点,不能由平台执行")
|
||
endpoint = await find_endpoint(session, bound_endpoint_ref(tool))
|
||
if endpoint is None:
|
||
raise EndpointError(f"能力「{name}」绑定的端点不存在,可能已被删除。请到「工具 → 端点」检查")
|
||
return cap, tool, endpoint
|
||
|
||
|
||
async def preview_request(session: AsyncSession, capability: Any, args: dict[str, Any], *,
|
||
environment: str) -> dict[str, Any]:
|
||
"""这次会发出的请求(不含认证头):动作确认卡、演练回执用。"""
|
||
_, tool, endpoint = await _resolve(session, capability)
|
||
req = build_request(endpoint, environment, tool.config or {}, args)
|
||
return {**req, "endpoint": endpoint.name, "environment": environment}
|
||
|
||
|
||
REHEARSAL_MESSAGE = "演练:写入能力不在这里真的发送,下面是将要发送的请求"
|
||
|
||
|
||
def rehearsal(endpoint: Endpoint, config: dict[str, Any], capability: Any, args: dict[str, Any]) -> dict[str, Any]:
|
||
"""写入能力在工具页试运行、工具库执行(不在运行里)时交回的演练回执:不发送,交回将要发送的方法、地址、查询参数、
|
||
请求体和请求头的名字(值不交回)。地址按探索运行定环境:有测试环境写测试环境的,没有时写正式运行要发往的生产环境。
|
||
对外写入只经调用工具节点发送:要确认、带幂等键、留生效记录(A5)——试运行绕过这些真发一次,打到的就是对方的系统。"""
|
||
envs = endpoint.environments or {}
|
||
environment = "test" if envs.get("test") else "prod"
|
||
req = build_request(endpoint, environment, config, args)
|
||
headers = sorted((envs.get(environment) or {}).get("headers") or {})
|
||
headers.append(endpoint.idempotency_header or "Idempotency-Key")
|
||
if capability is not None and getattr(capability, "credential_mode", None) == "service_on_behalf":
|
||
from app.auth.signing import ASSERTION_HEADER
|
||
|
||
headers.append(ASSERTION_HEADER)
|
||
return {"rehearsal": True, "message": REHEARSAL_MESSAGE, "endpoint": endpoint.name, "environment": environment,
|
||
"request": {"method": req["method"], "url": req["url"], "query": req["query"], "body": req["body"],
|
||
"headers": headers}}
|
||
|
||
|
||
def describe_status(status: int) -> str:
|
||
"""对方回的 HTTP 状态码怎么说(非 2xx)。试运行、工具库执行和端点的测试连接共用,说法只有这一份。"""
|
||
if 300 <= status < 400:
|
||
return f"对方返回跳转(HTTP {status}),平台不跟随跳转"
|
||
if status in (401, 403):
|
||
return f"对方拒绝了认证(HTTP {status})"
|
||
if status == 404:
|
||
return "对方系统上没有这个地址(HTTP 404)"
|
||
if 400 <= status < 500:
|
||
return f"对方拒绝了这个请求(HTTP {status})"
|
||
if status >= 500:
|
||
return f"对方系统出错(HTTP {status})"
|
||
return f"对方返回 HTTP {status}"
|
||
|
||
|
||
def not_sent_reason(e: BaseException, timeout_s: float) -> str:
|
||
"""请求没拿到响应的原因(测试连接用)。连接没建立、请求在发出之前就被拒(头不合法)和发出之后没回分开说。"""
|
||
text = str(e).lower()
|
||
if isinstance(e, httpx.ConnectTimeout):
|
||
return f"无法连接:{timeout_s:g} 秒内没有连上,请检查地址、端口和网络"
|
||
if isinstance(e, httpx.ConnectError):
|
||
if "certificate" in text or "ssl" in text:
|
||
return "无法连接:对方证书校验没有通过。内网自签的证书可在端点里关闭「校验证书」"
|
||
return "无法连接:地址无法解析、端口上没有服务,或网络不通"
|
||
if isinstance(e, httpx.LocalProtocolError):
|
||
return "请求没有发出:请求头的名称或值不合法,请检查认证头"
|
||
if isinstance(e, httpx.TimeoutException):
|
||
return f"已连接,但对方 {timeout_s:g} 秒内没有响应"
|
||
return "连接中途断开,没有拿到响应"
|
||
|
||
|
||
def on_behalf_parts(on_behalf: Any) -> tuple[str | None, str | None]:
|
||
"""on_behalf 的两种写法 → (发起人, 确认人)。
|
||
|
||
- 字符串(老写法,工具库试用、agent 里的读取):就是发起人,没有确认人;
|
||
- {"sub": 发起人, "act": 确认人}:调用工具节点的写入。sub 一律是运行的发起人,确认动作的人放在 act(D-b)。"""
|
||
if isinstance(on_behalf, dict):
|
||
sub, act = on_behalf.get("sub"), on_behalf.get("act")
|
||
return (str(sub) if sub else None), (str(act) if act else None)
|
||
return (str(on_behalf) if on_behalf else None), None
|
||
|
||
|
||
async def endpoint_request(session: AsyncSession, capability: Any, args: dict[str, Any], *, environment: str,
|
||
idem_key: str | None = None, on_behalf: str | dict[str, Any] | None = None,
|
||
run_id: str | None = None, node_id: str | None = None,
|
||
tool: CustomTool | None = None) -> EndpointResult:
|
||
"""平台执行器(registry.endpoint_request 的实现,签名同附录 C)。environment 由调用方按运行类别定(pick_environment);
|
||
传 test 而端点没有测试环境时演练。超时、连接中断交 unknown=True:对方可能已经处理了,写入要转核实,不能重发。
|
||
on_behalf 是发起人(字符串),或者 {"sub": 发起人, "act": 确认人}(on_behalf_parts)。tool 见 _resolve。"""
|
||
try:
|
||
cap, tool, endpoint = await _resolve(session, capability, tool)
|
||
envs = endpoint.environments or {}
|
||
if environment == "test" and not envs.get("test"):
|
||
environment = "simulated"
|
||
req = build_request(endpoint, environment, tool.config or {}, args)
|
||
except EndpointError as e:
|
||
return EndpointResult(status=None, body=None, error=str(e), environment=environment)
|
||
shown = {k: req[k] for k in ("method", "path", "query", "body")}
|
||
if environment == "simulated":
|
||
return EndpointResult(status=None, body={"simulated": True, "endpoint": endpoint.name, "request": shown},
|
||
environment="simulated")
|
||
env = envs.get(environment)
|
||
if not env:
|
||
return EndpointResult(status=None, body=None, environment=environment,
|
||
error=f"端点「{endpoint.name}」没有配置{_ENV_LABEL.get(environment, environment)}")
|
||
try:
|
||
await assert_safe_url(req["url"], allow_intranet=bool(env.get("allow_intranet")))
|
||
except UnsafeUrlError as e:
|
||
return EndpointResult(status=None, body=None, error=str(e), environment=environment)
|
||
|
||
headers = vault.reveal(env.get("headers"))
|
||
if idem_key:
|
||
headers[endpoint.idempotency_header or "Idempotency-Key"] = idem_key
|
||
if cap is not None and cap.credential_mode == "service_on_behalf":
|
||
from app.auth.signing import ASSERTION_HEADER, assertion
|
||
|
||
from app.auth import current_mode
|
||
|
||
subject, actor = on_behalf_parts(on_behalf)
|
||
# 开了登录,发起人就是认证过的用户名;关登录时是自报的署名,断言里照实写 verified=false
|
||
headers[ASSERTION_HEADER] = assertion(endpoint.name, subject, actor=actor,
|
||
verified=bool(subject) and current_mode() != "off")
|
||
if problem := vault.header_problem(headers):
|
||
# 存量的坏头(加校验之前存进去的 {"": ""}):发不出去,照实说是哪一项、去哪里改,不是一句 LocalProtocolError
|
||
return EndpointResult(status=None, body=None, environment=environment,
|
||
error=f"端点「{endpoint.name}」的{_ENV_LABEL.get(environment, environment)}认证头有误:"
|
||
f"{problem}。请到「工具 → 端点」修改")
|
||
try:
|
||
async with _client(timeout=endpoint.timeout_s or settings.http_tool_timeout, follow_redirects=False,
|
||
trust_env=False, verify=env.get("verify_tls", True) is not False) as client:
|
||
resp = await client.request(req["method"], req["url"], params=req["query"] or None, headers=headers,
|
||
json=req["body"] if req["body"] is not None else None)
|
||
except (httpx.ConnectError, httpx.ConnectTimeout) as e:
|
||
# 连接没建立:请求没发出去,是确定的失败
|
||
return EndpointResult(status=None, body=None, error=f"无法连接端点「{endpoint.name}」:{e}",
|
||
environment=environment)
|
||
except httpx.LocalProtocolError:
|
||
# 请求在发出之前就被拒(头、地址不合法):同样没发出去,不能当成结果未知去核实
|
||
return EndpointResult(status=None, body=None, environment=environment,
|
||
error=f"端点「{endpoint.name}」的请求没有发出:请求头的名称或值不合法,"
|
||
"请到「工具 → 端点」检查认证头")
|
||
except httpx.HTTPError as e:
|
||
# 发出去之后超时、连接中断:对方可能已经处理了,结果未知
|
||
return EndpointResult(status=None, body=None, unknown=True, environment=environment,
|
||
error=f"端点「{endpoint.name}」没有返回确定的结果:{type(e).__name__}")
|
||
|
||
body = _parse(resp)
|
||
snapshot: str | None = None
|
||
if (cap is not None and cap.tabular and effective_effect(cap) == "read" and 200 <= resp.status_code < 300):
|
||
try:
|
||
snapshot = await _store_snapshot(cap, endpoint, environment, shown, resp.status_code, body,
|
||
run_id=run_id, node_id=node_id)
|
||
except EndpointError as e:
|
||
return EndpointResult(status=resp.status_code, body=body, error=str(e), environment=environment)
|
||
return EndpointResult(status=resp.status_code, body=body, snapshot_artifact=snapshot, environment=environment)
|
||
|
||
|
||
async def _store_snapshot(cap: Capability, endpoint: Endpoint, environment: str, request: dict[str, Any],
|
||
status: int, body: Any, *, run_id: str | None, node_id: str | None) -> str:
|
||
from app.core.artifact_store import put_json
|
||
|
||
columns, rows, truncated = tabulate(body, cap.tabular or {})
|
||
content: dict[str, Any] = {"source": endpoint.name, "capability": cap.name, "columns": columns, "rows": rows,
|
||
"row_count": len(rows), "truncated": truncated,
|
||
"request": {**request, "environment": environment}, "status": status,
|
||
"response": body}
|
||
if hidden := masked_columns_of(cap.tabular or {}, cap.masked or []):
|
||
content["mask_columns"] = hidden
|
||
return await put_json(content, kind=SNAPSHOT_KIND, run_id=run_id or "", node_id=node_id or "",
|
||
meta={"capability": cap.name, "endpoint": endpoint.name})
|
||
|
||
|
||
def tool_result(result: EndpointResult) -> dict[str, Any]:
|
||
"""交给模型的结果。落了快照的照查询快照的样子交(带 artifact),别的给状态码和响应体。"""
|
||
if result.error and not result.unknown:
|
||
return {"error": result.error}
|
||
if result.unknown:
|
||
return {"error": result.error, "unknown": True}
|
||
if result.environment == "simulated":
|
||
return {"simulated": True, "message": "演练:探索运行里端点没有测试环境,未发送请求", **(result.body or {})}
|
||
if result.snapshot_artifact:
|
||
from app.core.artifact_store import load
|
||
|
||
snapshot = load(result.snapshot_artifact) or {}
|
||
return {"artifact": result.snapshot_artifact,
|
||
**{k: snapshot.get(k) for k in ("source", "columns", "rows", "truncated", "request")}}
|
||
return {"status": result.status, "data": result.body, "environment": result.environment}
|
||
|
||
|
||
async def probe(endpoint: Endpoint, environment: str) -> dict[str, Any]:
|
||
"""测一下这个环境:对 base_url 带认证头发一个 GET。{ok, reachable, status, message}:reachable 是连没连上、拿没拿到
|
||
响应;ok 只在 2xx 时为真——能连上但对方回 404(基础地址上没有内容)、401(认证没过)不能说成「连接成功」。"""
|
||
env = (endpoint.environments or {}).get(environment)
|
||
if not env:
|
||
return {"ok": False, "reachable": False, "status": None,
|
||
"message": f"端点「{endpoint.name}」没有配置{_ENV_LABEL.get(environment, environment)}"}
|
||
headers = vault.reveal(env.get("headers"))
|
||
if problem := vault.header_problem(headers):
|
||
return {"ok": False, "reachable": False, "status": None, "message": f"认证头有误,请求没有发出:{problem}"}
|
||
timeout = endpoint.timeout_s or settings.http_tool_timeout
|
||
try:
|
||
await assert_safe_url(env["base_url"], allow_intranet=bool(env.get("allow_intranet")))
|
||
async with _client(timeout=timeout, follow_redirects=False, trust_env=False,
|
||
verify=env.get("verify_tls", True) is not False) as client:
|
||
resp = await client.get(env["base_url"], headers=headers)
|
||
except UnsafeUrlError as e:
|
||
return {"ok": False, "reachable": False, "status": None, "message": str(e)}
|
||
except httpx.HTTPError as e:
|
||
return {"ok": False, "reachable": False, "status": None, "message": not_sent_reason(e, timeout)}
|
||
status = resp.status_code
|
||
if 200 <= status < 300:
|
||
return {"ok": True, "reachable": True, "status": status, "message": f"连接成功:基础地址返回 HTTP {status}"}
|
||
message = f"能连上,但{describe_status(status)}"
|
||
if status == 404:
|
||
# 不少系统只在具体路径上提供接口,基础地址本身就是 404:说清这一点,别让人以为端点配错了
|
||
message += "。对方只在具体路径上提供接口时这不影响调用,可用一个读取能力试运行确认"
|
||
elif status in (401, 403):
|
||
message += ",请检查认证头"
|
||
return {"ok": False, "reachable": True, "status": status, "message": message}
|