diff --git a/backend/app/api/conversations.py b/backend/app/api/conversations.py index 4e1b54cd..4111e813 100644 --- a/backend/app/api/conversations.py +++ b/backend/app/api/conversations.py @@ -13,15 +13,16 @@ from __future__ import annotations +from datetime import datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field -from sqlalchemy import delete, func, select +from sqlalchemy import and_, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.db.base import get_session, utcnow -from app.db.models import Conversation, ConversationTurn +from app.db.models import Approval, Conversation, ConversationTurn, Run router = APIRouter(prefix="/api/conversations", tags=["conversations"]) @@ -71,6 +72,8 @@ class TurnPatch(BaseModel): status: str | None = Field(default=None, pattern="^(running|done|error)$") error: str | None = None review: dict[str, Any] | None = None + #: 可信度元数据,前端整块写、整块读,后端不解读它的内容(列表状态除外,见 last_status) + meta: dict[str, Any] | None = None class TurnOut(BaseModel): @@ -85,6 +88,8 @@ class TurnOut(BaseModel): error: str #: 复核结论。None = 这一轮没复核过,和「复核过、没发现问题」不是一回事 review: dict[str, Any] | None = None + #: 可信度元数据。老轮次挂在 review.meta 下,读出来时统一放到这里 + meta: dict[str, Any] | None = None created_at: Any = None model_config = {"from_attributes": True} @@ -101,6 +106,11 @@ class ConversationOut(BaseModel): turn_count: int = 0 #: 列表里给个副标题,让人一眼认出是哪次聊天 last_question: str = "" + #: 最后一轮停在哪:running | waiting | error | cancelled | suspended | done, + #: 没有轮次时为 None。见 last_status() + last_status: str | None = None + #: 最后一轮的运行。建图阶段就断了的没有 + last_run_id: str | None = None model_config = {"from_attributes": True} @@ -114,14 +124,119 @@ class ConversationDetail(ConversationOut): model_config = {"from_attributes": False} +def turn_meta(turn: Any) -> dict[str, Any] | None: + """这一轮的元数据:meta 列优先,老数据退回 review.meta。""" + if isinstance(turn.meta, dict): + return turn.meta + bridged = turn.review.get("meta") if isinstance(turn.review, dict) else None + return bridged if isinstance(bridged, dict) else None + + +def _turn_out(turn: ConversationTurn) -> TurnOut: + out = TurnOut.model_validate(turn) + out.meta = turn_meta(turn) + return out + + def _detail(row: Conversation, turns: list[ConversationTurn]) -> ConversationDetail: base = ConversationOut.model_validate(row) return ConversationDetail( **base.model_dump(), - turns=[TurnOut.model_validate(t) for t in turns], + turns=[_turn_out(t) for t in turns], ) +#: 还没有运行的轮次停在 running 超过这么久,就当建图断了。建图跟着浏览器那条流走, +#: 页面一关就停,轮次却一直是 running;最长的正常情况是模型超时再加两轮自查修正 +BUILD_STALE = timedelta(minutes=15) + + +def last_status( + turn: Any, run_status: str | None, pending: bool, now: datetime | None = None, +) -> str: + """一轮停在哪。轮次状态只有 running / done / error 三种,细分要看运行和元数据。 + + 轮次状态是前端写的:页面关掉、服务重启时它停在 running,这时要以运行的状态为准; + 运行也没有的,是断在了建图阶段。已经收尾的轮次以轮次自己为准——它写下的就是 + 用户看到的结局(取消、服务重启挂起在 error 里分开记)。 + """ + outcome = (turn_meta(turn) or {}).get("outcome") + if turn.status == "done": + return "done" + if turn.status == "error": + if outcome == "cancelled" or (turn.error or "").strip() == "已取消": + return "cancelled" + if outcome == "suspended": + # 挂起之后可能已经在别处接着跑了 + live = _from_run(run_status, pending) + return live if live in ("running", "waiting") else "suspended" + return "error" + live = _from_run(run_status, pending) + if live: + return live + updated = turn.updated_at + if updated is not None and updated.tzinfo is None: + updated = updated.replace(tzinfo=timezone.utc) + stale = updated is not None and (now or utcnow()) - updated > BUILD_STALE + return "error" if stale else "running" + + +def _from_run(run_status: str | None, pending: bool) -> str | None: + return { + "queued": "running", "running": "running", + "interrupted": "waiting" if pending else "suspended", + "succeeded": "done", "failed": "error", "cancelled": "cancelled", + }.get(run_status or "") + + +async def _last_turns( + session: AsyncSession, ids: list[str] +) -> dict[str, ConversationTurn]: + """每个会话 seq 最大的那一轮。一次查完,列表长了也只是一次往返。""" + latest = ( + select(ConversationTurn.conversation_id, func.max(ConversationTurn.seq).label("seq")) + .where(ConversationTurn.conversation_id.in_(ids)) + .group_by(ConversationTurn.conversation_id) + .subquery() + ) + rows = (await session.execute( + select(ConversationTurn).join(latest, and_( + ConversationTurn.conversation_id == latest.c.conversation_id, + ConversationTurn.seq == latest.c.seq, + )) + )).scalars() + return {t.conversation_id: t for t in rows} + + +async def _statuses( + session: AsyncSession, turns: dict[str, ConversationTurn] +) -> dict[str, tuple[str, str | None]]: + """会话 id → (last_status, last_run_id)。运行状态和待审批各一次查询。""" + run_of = { + cid: t.run_id or (turn_meta(t) or {}).get("runId") or None for cid, t in turns.items() + } + run_ids = {r for r in run_of.values() if r} + runs: dict[str, str] = {} + waiting: set[str] = set() + if run_ids: + runs = dict((await session.execute( + select(Run.id, Run.status).where(Run.id.in_(run_ids)) + )).all()) + stopped = [r for r, st in runs.items() if st == "interrupted"] + if stopped: + waiting = set((await session.execute( + select(Approval.run_id).where( + Approval.run_id.in_(stopped), Approval.status == "pending") + )).scalars()) + now = utcnow() + out: dict[str, tuple[str, str | None]] = {} + for cid, turn in turns.items(): + run_id = run_of[cid] if run_of[cid] in runs else None + out[cid] = (last_status(turn, runs.get(run_id or ""), run_id in waiting, now), + run_id) + return out + + # -------------------------------------------------------------------------- # 会话 # -------------------------------------------------------------------------- @@ -152,34 +267,24 @@ async def list_conversations( if not rows: return [] - # 轮数和最后一问一次查完。每条会话各查一次的话,列表长了就是几十次往返 + # 轮数、最后一轮、它的运行状态一次查完。每条会话各查一次的话,列表长了就是 + # 几十次往返。状态要带上:没打开过的会话也得标得出「在等审批」「失败了」 ids = [r.id for r in rows] counts = dict((await session.execute( select(ConversationTurn.conversation_id, func.count(ConversationTurn.id)) .where(ConversationTurn.conversation_id.in_(ids)) .group_by(ConversationTurn.conversation_id) )).all()) - last_seq = dict((await session.execute( - select(ConversationTurn.conversation_id, func.max(ConversationTurn.seq)) - .where(ConversationTurn.conversation_id.in_(ids)) - .group_by(ConversationTurn.conversation_id) - )).all()) - last_questions: dict[str, str] = {} - if last_seq: - pairs = list(last_seq.items()) - rows_q = list((await session.execute( - select(ConversationTurn.conversation_id, ConversationTurn.seq, ConversationTurn.question) - .where(ConversationTurn.conversation_id.in_([c for c, _ in pairs])) - )).all()) - for conv_id, seq, question in rows_q: - if last_seq.get(conv_id) == seq: - last_questions[conv_id] = question or "" + last = await _last_turns(session, ids) + status = await _statuses(session, last) out: list[ConversationOut] = [] for row in rows: item = ConversationOut.model_validate(row) item.turn_count = int(counts.get(row.id, 0)) - item.last_question = last_questions.get(row.id, "") + if row.id in last: + item.last_question = last[row.id].question or "" + item.last_status, item.last_run_id = status[row.id] out.append(item) return out @@ -212,6 +317,8 @@ async def get_conversation( out = _detail(row, turns) out.turn_count = len(turns) out.last_question = turns[-1].question if turns else "" + if turns: + out.last_status, out.last_run_id = (await _statuses(session, {row.id: turns[-1]}))[row.id] return out @@ -275,7 +382,7 @@ async def create_turn( await session.commit() await session.refresh(turn) - return TurnOut.model_validate(turn) + return _turn_out(turn) @router.patch("/{conversation_id}/turns/{turn_id}", response_model=TurnOut) @@ -290,7 +397,7 @@ async def patch_turn( raise HTTPException(404, "这一轮对话不存在,可能已经被删了") for field in ( - "question", "answer", "explanation", "graph", "run_id", "status", "error", "review", + "question", "answer", "explanation", "graph", "run_id", "status", "error", "review", "meta", ): value = getattr(payload, field) if value is not None: @@ -301,7 +408,7 @@ async def patch_turn( conversation.last_active_at = utcnow() await session.commit() await session.refresh(turn) - return TurnOut.model_validate(turn) + return _turn_out(turn) # -------------------------------------------------------------------------- diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 87540df1..52489510 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -1,7 +1,9 @@ from __future__ import annotations import asyncio +import copy import json +import re import time from typing import Any @@ -11,11 +13,11 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession # 起别名:本文件底下有个叫 explain 的接口,generate 里又有个局部变量叫 raw,同名会互相盖掉 -from app.api.errors import explain as explain_error, graph_error, not_configured, raw as raw_error +from app.core.errors import explain as explain_error, graph_error, not_configured, raw as raw_error from app.db.base import get_session from app.db.models import Workflow from app.engine.layout import CORRIDOR_MIN, MIN_ROW_GAP, NODE_W, _height, auto_layout -from app.engine.schema import GraphSpec, NodeType, validate_graph +from app.engine.schema import GraphSpec, NodeType, ValidationIssue, validate_graph from app.engine.state import message_text from app.providers.factory import ModelSpec, ProviderNotConfigured, get_chat_model from app.tools.registry import all_specs @@ -23,7 +25,40 @@ from app.tools.registry import all_specs router = APIRouter(prefix="/api/copilot", tags=["copilot"]) -async def _tool_catalog(session: AsyncSession) -> str: +async def _sources(session: AsyncSession, scope: list[str] | None) -> list[Any]: + """这一轮给助手看的数据源:启用着的,限定了范围就只留点名的(id、名字都认)。 + + 点名的一个都不在(删了、停用了)就直接拒绝:悄悄退回「全部数据源」等于 + 无视用户的限定,而他正是因为想换一个库问才点的。 + """ + from app.db.models import DataSource + + rows = list(( + await session.execute( + select(DataSource).where(DataSource.enabled.is_(True)).order_by(DataSource.name) + ) + ).scalars()) + wanted = {s.strip() for s in scope or [] if isinstance(s, str) and s.strip()} + if not wanted: + return rows + picked = [r for r in rows if r.id in wanted or r.name in wanted] + if not picked: + raise HTTPException( + 400, "限定的数据源都不在了:可能已经被删掉或停用。去掉限定再问," + "或者到「数据」页确认它还在、而且是启用的", + ) + return picked + + +def _scope_note(rows: list[Any], scoped: bool) -> str: + if not scoped: + return "" + names = "、".join(r.name for r in rows) + return (f"\n\n这一轮用户把数据源限定为:{names}。只查它们,只能用它们的 db_query__ / " + "db_schema__ 工具;前几轮用过别的库,这一轮也不要沿用") + + +def _tool_catalog(rows: list[Any]) -> str: """给 Copilot 看的工具清单。 all_specs() 只有静态注册的内置工具,数据源工具是运行时按库动态生成的 @@ -36,14 +71,8 @@ async def _tool_catalog(session: AsyncSession) -> str: ] from app.data import introspect as _introspect - from app.db.models import DataSource from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX - rows = list(( - await session.execute( - select(DataSource).where(DataSource.enabled.is_(True)).order_by(DataSource.name) - ) - ).scalars()) for row in rows: tables = _introspect.table_names(row) listed = "、".join(tables[:12]) + (f" 等 {len(tables)} 个对象" if len(tables) > 12 else "") @@ -68,16 +97,10 @@ _DATASOURCE_BUDGET_CHARS = 6000 _DETAIL_TABLE_LIMIT = 40 -async def _datasource_section(session: AsyncSession) -> str: +def _datasource_section(rows: list[Any]) -> str: """数据源与结构摘要。没有数据源时返回空串,不占 prompt。""" from app.data import introspect as _introspect - from app.db.models import DataSource - rows = list(( - await session.execute( - select(DataSource).where(DataSource.enabled.is_(True)).order_by(DataSource.name) - ) - ).scalars()) if not rows: return "" @@ -190,6 +213,8 @@ class GenerateIn(BaseModel): intent: str = Field(default="build") #: 属于哪次对话。带上它,这一轮才知道前面聊过什么 conversation_id: str | None = None + #: 这一轮只查这几个数据源(id 或名字)。不传或空表示不限 + datasource_ids: list[str] | None = None def _user_message(payload: GenerateIn, *, patch: bool) -> str: @@ -395,6 +420,8 @@ class GenerateOut(BaseModel): explanation: str = "" issues: list[dict[str, Any]] = Field(default_factory=list) layout: dict[str, Any] = Field(default_factory=dict) + #: 改图前后都在、工具绑定却变了的节点和成员,见 tool_changes() + tool_changes: list[dict[str, Any]] = Field(default_factory=list) # -------------------------------------------------------------------------- @@ -545,8 +572,10 @@ async def generate( 产物会经过和手工编排完全相同的校验与排版,所以生成完就是可运行、可读的, 而不是一段还要人去修的 JSON。 """ - tool_list = await _tool_catalog(session) - datasources = await _datasource_section(session) + sources = await _sources(session, payload.datasource_ids) + scope = {r.name for r in sources} if payload.datasource_ids else None + tool_list = _tool_catalog(sources) + datasources = _datasource_section(sources) + _scope_note(sources, scope is not None) system = ( "你是一个 agent 工作流编排专家。根据用户需求产出一张可执行的工作流图。\n\n" @@ -620,11 +649,16 @@ async def generate( spec, layout = _layout_keeping(spec, _pinned_positions(payload.base_graph)) report = validate_graph(spec) + out = spec.model_dump(mode="json") + changes = tool_changes((payload.base_graph or {}).get("nodes") or [], out["nodes"]) return GenerateOut( - graph=spec.model_dump(mode="json"), + graph=out, explanation=str(raw.get("explanation", "")), - issues=[i.model_dump() for i in report.issues], + issues=[i.model_dump() for i in report.issues] + + _issue_dicts(scope_issues(out["nodes"], scope), "datasource_out_of_scope") + + dropped_tool_warnings(changes, payload.instruction), layout=layout, + tool_changes=changes, ) @@ -638,13 +672,16 @@ _STREAM_PROTOCOL = """\ - 可用操作: {"op":"plan","summary":"一句话说明打算怎么搭"} ← 第一行 {"op":"add_node","node":{"id":"...","type":"...","label":"中文标签","config":{...}}} - {"op":"update_node","id":"...","label":"...","config":{...}} ← 修改现有节点(config 整体替换) + {"op":"update_node","id":"...","label":"...","config":{...}} ← 修改现有节点(config 只写要改的字段) {"op":"remove_node","id":"..."} {"op":"add_edge","edge":{"source":"...","target":"...","sourceHandle":"..."}} {"op":"remove_edge","source":"...","target":"...","sourceHandle":"..."} {"op":"done","explanation":"两三句话说明这张图怎么跑","run":true} ← 最后一行 - 按执行顺序添加节点(先入口后出口);每加一个节点,立刻把连向它的边(两端都已存在的)输出出来,让图连贯地生长 - 修改现有图时只输出改动,没提到的节点不要动 +- update_node 按字段合并:config 只写要改的字段,没写的(tools、assign_to…)原样保留; + 要删掉某个字段就写成 null。supervisor 的 agents 按成员 name 合并:只写要改的成员和字段, + 成员没写 tools 就沿用它原来的工具;新名字是新增成员;去掉成员写 {"name":"…","remove":true} - done 里的 run:这张图建完要不要立刻执行。默认 true;用户要的是**流程本身** ("设计一个每天跑的工作流")时给 false —— 他要的是这张图,不是这一次的结果 - 如果这句话根本不需要工作流(见下面的路径约定),**第一行也是最后一行**就输出: @@ -669,6 +706,64 @@ def _parse_op_line(line: str) -> dict[str, Any] | None: _NODE_TYPES = frozenset(t.value for t in NodeType) +#: 协作成员条目里写 "remove": true 就把这个成员拿掉。不复用 null:null 在合并里 +#: 的意思是「删这个字段」,成员本身不是一个字段 +_REMOVE_MEMBER = "remove" + + +def _member_key(member: dict[str, Any], index: int) -> str: + # 和 engine/nodes/multi.py 的叫法一致:没起名字的成员按位置叫 agentN + return str(member.get("name") or f"agent{index}") + + +def _merge_fields(old: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]: + """按字段合并:写了的覆盖,写 null 的删掉,没写的原样保留。返回新 dict。""" + out = dict(old) + for key, value in patch.items(): + if value is None: + out.pop(key, None) + else: + out[key] = value + return out + + +def _merge_members(old: list[Any], patch: list[Any]) -> list[Any]: + """协作成员按 name 合并。原有成员保持原来的次序,新名字接在后面。""" + merged: list[Any] = list(old) + where = {_member_key(m, i): i for i, m in enumerate(merged) if isinstance(m, dict)} + dropped: set[int] = set() + for i, entry in enumerate(patch): + if not isinstance(entry, dict): + continue + key = _member_key(entry, i) + if entry.get(_REMOVE_MEMBER) is True: + if key in where: + dropped.add(where[key]) + continue + fields = {k: v for k, v in entry.items() if k != _REMOVE_MEMBER} + if key in where: + merged[where[key]] = _merge_fields(merged[where[key]], fields) + else: + where[key] = len(merged) + merged.append({k: v for k, v in fields.items() if v is not None}) + return [m for i, m in enumerate(merged) if i not in dropped] + + +def merge_node_config( + node_type: str, old: dict[str, Any], patch: dict[str, Any] +) -> dict[str, Any]: + """update_node 的 config 怎么落到节点上。前端 store/studio.ts 必须是同一套规则。 + + 以前是整体替换:模型只想改一句提示词,照协议也得把整份 config 抄一遍, + 漏抄的字段就没了。真出过事——改写提示词时漏了 tools,三个要查库的 agent + 全丢了工具,一次库都没查,最后把一堆工具调用的原始标记当答案交了出去。 + 改成按字段合并之后,漏写等于不改;真要删一个字段得显式写 null。 + """ + if node_type == NodeType.SUPERVISOR.value and isinstance(patch.get("agents"), list): + members = old.get("agents") if isinstance(old.get("agents"), list) else [] + patch = {**patch, "agents": _merge_members(members, patch["agents"])} + return _merge_fields(old, patch) + def _apply_op(nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], op: dict[str, Any]) -> bool: """把一个操作应用到服务端维护的图状态。返回是否真的改了图。""" @@ -690,10 +785,12 @@ def _apply_op(nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], op: node = nodes.get(op.get("id") or "") if not node: return False + data = node.setdefault("data", {}) if op.get("label") is not None: - node["data"]["label"] = op["label"] - if op.get("config") is not None: - node["data"]["config"] = op["config"] + data["label"] = op["label"] + if isinstance(op.get("config"), dict): + data["config"] = merge_node_config( + str(node.get("type") or ""), data.get("config") or {}, op["config"]) return True if kind == "remove_node": node_id = op.get("id") @@ -809,21 +906,250 @@ def _sse(obj: dict[str, Any]) -> str: return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n" -def _blocking_issues(nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]]) -> list[Any] | None: - """按运行时同一套校验,挑出会挡住运行的问题。图本身不成形时返回 None。""" +def _blocking_issues( + nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], scope: set[str] | None = None, +) -> list[dict[str, Any]] | None: + """按运行时同一套校验,挑出会挡住运行的问题。图本身不成形时返回 None。 + + 限定了数据源时,用了范围外的库也算:用户点了「只查这个库」,拿别的库的数 + 回答他,比跑不起来更糟——答案看起来是对的。 + + 每条是 ValidationIssue 的字典形状(node_id、field 都在),和 final.issues 一样: + 界面靠 node_id 定位卡片、靠 field 落到具体的输入框,不用从一行字里抠节点 id。 + """ try: spec = GraphSpec.model_validate({"nodes": list(nodes.values()), "edges": edges}) except Exception: # noqa: BLE001 - 结构不合法留给收尾那一步报 return None - return [i for i in validate_graph(spec).issues if i.level == "error"] + return ([i.model_dump() for i in validate_graph(spec).issues if i.level == "error"] + + _issue_dicts(scope_issues(list(nodes.values()), scope), "datasource_out_of_scope")) -def _issue_line(issue: Any) -> str: - return f"「{issue.node_id}」{issue.message}" if issue.node_id else issue.message +def _source_of(tool: str) -> str | None: + from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX + + for prefix in (QUERY_PREFIX, SCHEMA_PREFIX): + if tool.startswith(prefix): + return tool[len(prefix):] + return None + + +def scope_issues(nodes: list[dict[str, Any]], scope: set[str] | None) -> list[ValidationIssue]: + """用了限定范围之外的数据源的节点,一个节点一条。""" + if scope is None: + return [] + out: list[ValidationIssue] = [] + for node in nodes: + cfg = (node.get("data") or {}).get("config") or {} + names = _tool_names(cfg.get("tools")) + _tool_names([cfg.get("tool")]) + for member in cfg.get("agents") or []: + if isinstance(member, dict): + names += _tool_names(member.get("tools")) + outside = sorted({src for src in map(_source_of, names) if src and src not in scope}) + if outside: + out.append(ValidationIssue( + level="error", node_id=node.get("id"), + message=f"用了限定范围之外的数据源 {'、'.join(outside)}:这一轮只查 " + f"{'、'.join(sorted(scope))},改用它们的 db_query__ / db_schema__ 工具", + )) + return out + + +def _issue_dicts(issues: list[ValidationIssue], code: str) -> list[dict[str, Any]]: + return [{**i.model_dump(), "code": code} for i in issues] + + +def _issue_line(issue: dict[str, Any]) -> str: + """交回模型的修正请求里,一条问题一行,节点 id 在前。""" + return f"「{issue['node_id']}」{issue['message']}" if issue.get("node_id") else issue["message"] + + +# -------------------------------------------------------------------------- +# 工具绑定变化:改图前后,每个 agent 和每个协作成员各绑了哪些工具 +# -------------------------------------------------------------------------- + + +def _tool_names(value: Any) -> list[str]: + return [t for t in value if isinstance(t, str) and t] if isinstance(value, list) else [] + + +def _tool_bindings(nodes: list[dict[str, Any]]) -> dict[tuple[str, str | None], dict[str, Any]]: + """(节点 id, 成员名) → {label, tools, field}。成员名为 None 的是 agent 节点本身。""" + out: dict[tuple[str, str | None], dict[str, Any]] = {} + for node in nodes: + data = node.get("data") or {} + cfg = data.get("config") or {} + label = data.get("label") or node.get("id") + if node.get("type") == NodeType.AGENT.value: + out[(node["id"], None)] = {"label": label, "field": "tools", + "tools": _tool_names(cfg.get("tools"))} + elif node.get("type") == NodeType.SUPERVISOR.value: + for i, member in enumerate(cfg.get("agents") or []): + if isinstance(member, dict): + out[(node["id"], _member_key(member, i))] = { + "label": label, "field": f"agents[{i}].tools", + "tools": _tool_names(member.get("tools")), + } + return out + + +def tool_changes( + before: list[dict[str, Any]], after: list[dict[str, Any]] +) -> list[dict[str, Any]]: + """改图前后都在、而绑定的工具不一样了的节点和成员,按改后的图排序。 + + 新加的、删掉的节点不算:那是结构变化,改图回执本来就会列出来。这里要抓的 + 是「节点还在、工具悄悄没了」——画布上看起来什么都没变,跑起来才发现查不了库。 + """ + old = _tool_bindings(before) + out: list[dict[str, Any]] = [] + for (node_id, member), now in _tool_bindings(after).items(): + was = old.get((node_id, member)) + if was is None or set(was["tools"]) == set(now["tools"]): + continue + out.append({ + "node_id": node_id, "label": now["label"], "member": member, "field": now["field"], + "before": was["tools"], "after": now["tools"], + "added": [t for t in now["tools"] if t not in was["tools"]], + "removed": [t for t in was["tools"] if t not in now["tools"]], + }) + return out + + +# 指令里有没有让删工具。只认「删除动词直接管着工具」:动词和「工具 / 工具名」 +# 挨着、在同一个分句里、中间没有夹别的动作。以前是整句里有「不要 / 不用 / 取消」、 +# 别处又有「工具」就算——「这次要真的用工具查库,不要编造数字」被当成让删工具, +# 警告关掉,没工具的图照样自动运行。宁可在真让删时多提醒一句,也不能漏掉这种。 + +#: 删除动词。「不要 / 不用 / 别」只在直接接着工具(或接「用 / 绑」再接工具)时才算 +_DROP_VERB = re.compile( + r"去掉|去除|删掉|删除|删了|删去|移除|拿掉|撤掉|撤下|解绑|解除绑定|禁用|停用|卸掉|剔除|砍掉|清空|取消" + r"|(?:不要|不用|不需要|无需|用不着|不再|别)再?(?:使用|调用|绑定|用|绑|挂)?" + r"|\b(?:remov(?:e|ing)|drop(?:ping)?|unbind|detach|disable|without|get rid of|no longer use)\b" + r"|\b(?:don't|do not|never|stop) us(?:e|ing)\b" +) +#: 工具在前、动词在后:「把查表的工具去掉」「db_schema__shop 不要了」 +_DROP_AFTER = re.compile( + r"去掉|去除|删掉|删除|删了|删去|移除|拿掉|撤掉|撤下|解绑|禁用|停用|卸掉|剔除|砍掉|清空" + r"|(?:不要|不用|不需要|用不着)再?(?:用|绑)?(?:了|$)|\b(?:removed|dropped)\b" +) +#: 「只保留 X」:别的可以删,但 X 本身不能跟着没了,更不能删光。「只用」只认点了名的—— +#: 「只用工具查库」说的是别编,不是让删哪个工具 +_KEEP_ONLY = re.compile(r"(只保留|只留下|只留|仅保留|\bkeep only\b|\bonly keep\b)" + r"|(只用|仅用|\bonly use\b)") +#: 动词和工具之间夹了这些,动词管的就不是工具了:「不用改工具」「别用假设的方式调用工具」 +_GAP_BREAK = re.compile( + r"改|动|换|变成|编|捏造|虚构|假设|假装|模拟|猜|然后|接着|并且|同时|但|而是|让|把|将|保留|留着|保持|只|必须|一定" + r"|make up|made up|making up|\b(?:then|but|instead|change|edit|modify|keep|fake|invent|pretend)\b" +) +#: 否定。中文只往前看几个字(「不要去掉工具」「别把工具删了」),英文的否定离动词远,看整个分句 +_NEGATED = re.compile(r"不要|不用|不能|不许|不准|不得|不可|不必|不想|不希望|不让|无需|没让|别|勿|千万") +_NEGATED_EN = re.compile(r"\b(?:don't|dont|do not|never|no need|not|can't|cannot)\b") +#: 工具后面紧跟着这些,说的是留着它:「工具保留」「工具不用改」 +_KEPT_AFTER = re.compile(r"\s*(?:都|也|就|还|先)?(?:保留|留着|不变|照旧|保持|别动|不动|不要动|不用动|" + r"不要改|不用改|别改|别删|不要删|不能删)|\s*(?:stay|unchanged|as is)\b") +_CLAUSE = re.compile(r"[,,。;;!!??\n]|\.(?=\s|$)") +_GENERIC = ("工具", "tool", "tools") +_GAP, _GAP_AFTER, _LOOKBACK = 16, 8, 10 + + +def _tool_object(removed: list[str]) -> re.Pattern[str]: + """「工具」或一个工具名:这次删掉的、内置的,以及带 __ 的(db_query__x、MCP 工具)。 + + 只认像工具的名字:表名、字段名也是下划线写法,「去掉 sales_daily 相关的工具」 + 里点名的不是工具。 + """ + names = sorted({t.lower() for t in [*removed, *all_specs()] if t}, key=len, reverse=True) + return re.compile("|".join([*map(re.escape, names), r"[a-z][a-z0-9_]*__[a-z0-9_-]+", + "工具", r"\btools?\b"])) + + +def _negated(clause: str, pos: int) -> bool: + return bool(_NEGATED.search(clause, max(0, pos - _LOOKBACK), pos) + or _NEGATED_EN.search(clause, 0, pos)) + + +def _drop_request(instruction: str, removed: list[str]) -> tuple[bool, set[str], set[str] | None]: + """指令里让删了哪些工具:(泛指「工具」, 点了名的, 「只保留」留下的——没说只保留是 None)。""" + target = _tool_object(removed) + dropped: set[str] = set() + kept: set[str] | None = None + + def near(clause: str, start: int) -> re.Match[str] | None: + obj = target.search(clause, start) + return obj if obj and obj.start() - start <= _GAP else None + + for clause in _CLAUSE.split((instruction or "").lower()): + for verb in _DROP_VERB.finditer(clause): + obj = near(clause, verb.end()) + if not obj: + continue + gap = clause[verb.end():obj.start()] + if (_GAP_BREAK.search(gap) or _DROP_VERB.search(gap) or _negated(clause, verb.start()) + or _KEPT_AFTER.match(clause, obj.end())): + continue + dropped.add(obj.group()) + for obj in target.finditer(clause): + verb = _DROP_AFTER.search(clause, obj.end()) + if not verb or verb.start() - obj.end() > _GAP_AFTER: + continue + gap = clause[obj.end():verb.start()] + if _GAP_BREAK.search(gap) or _NEGATED.search(gap) or _negated(clause, obj.start()): + continue + dropped.add(obj.group()) + for verb in _KEEP_ONLY.finditer(clause): + obj = near(clause, verb.end()) + if obj and not (verb.group(2) and obj.group() in _GENERIC): + kept = (kept or set()) | {obj.group()} + return bool(dropped & set(_GENERIC)), dropped - set(_GENERIC), kept + + +def _unasked_drops(instruction: str, change: dict[str, Any]) -> list[str]: + """这次少掉的工具里,哪些是指令没让删的。""" + generic, named, kept = _drop_request(instruction, change["removed"]) + if generic: + return [] + + def asked(tool: str) -> bool: + if tool.lower() in named: + return True + # 「只保留 X」:X 以外的可以删;但一个都不剩就不是「保留」了 + return kept is not None and bool(change["after"]) and tool.lower() not in kept + + return [t for t in change["removed"] if not asked(t)] + + +def dropped_tool_warnings(changes: list[dict[str, Any]], instruction: str) -> list[dict[str, Any]]: + """工具集合变小了、而这一轮没让删工具的,逐个报 warning。 + + 只报变小的:把 db_query__a 换成 db_query__b 是正常的改图,数量没少不必大惊 + 小怪。也不打回去让模型重改——删工具有可能是对的,该让人看一眼,而不是再 + 赌一次模型。 + """ + out: list[dict[str, Any]] = [] + for c in changes: + if not c["removed"] or len(set(c["after"])) >= len(set(c["before"])): + continue + unasked = _unasked_drops(instruction, c) + if not unasked: + continue + who = f"「{c['label']}」" + (f"的成员「{c['member']}」" if c["member"] else "") + now = f" {'、'.join(c['after'])}" if c["after"] else "空" + said = ("这一轮的要求里没有提到去掉工具" if len(unasked) == len(c["removed"]) + else f"这一轮的要求里没有提到去掉 {'、'.join(unasked)}") + cost = ("没有绑定工具,它只能「假设」调用,查不了库" if not c["after"] + else f"{'、'.join(unasked)} 它就调不到了") + out.append({ + "level": "warning", "node_id": c["node_id"], "edge_id": None, + "code": "tools_dropped", "field": c["field"], + "message": f"{who}的工具从 {'、'.join(c['before'])} 变成了{now}。" + f"{said},确认一下是不是改漏了:{cost}", + }) + return out def _repair_request( - nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], errors: list[Any], + nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], errors: list[dict[str, Any]], ) -> str: graph = {"nodes": list(nodes.values()), "edges": edges} return ( @@ -832,7 +1158,8 @@ def _repair_request( "上线前自查(和真正运行时同一套规则)发现下面这些问题,不改的话这张图跑不起来:\n" + "\n".join(f"- {_issue_line(i)}" for i in errors) + "\n\n只修这些问题,别的不要动。用操作流输出修正:改节点用 update_node" - "(config 整体替换,要带上这个节点完整的 config);缺节点、缺边就补上。最后一行输出 done。" + "(config 只写要改的字段,没写的保持原样;要删掉一个字段写 null);" + "缺节点、缺边就补上。最后一行输出 done。" ) @@ -845,8 +1172,10 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g """ from fastapi.responses import StreamingResponse - tool_list = await _tool_catalog(session) - datasources = await _datasource_section(session) + sources = await _sources(session, payload.datasource_ids) + scope = {r.name for r in sources} if payload.datasource_ids else None + tool_list = _tool_catalog(sources) + datasources = _datasource_section(sources) + _scope_note(sources, scope is not None) system = ( "你是一个 agent 工作流编排专家。根据用户需求,以操作流的方式逐步搭出一张可执行的工作流图。\n\n" f"{NODE_REFERENCE}\n\n可用工具:\n{tool_list}{datasources}\n\n" @@ -865,8 +1194,9 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g # 用户摆好的坐标,收尾排版时原样留着 pinned = _pinned_positions(payload.base_graph) if payload.base_graph and payload.base_graph.get("nodes"): + # 拷一份再改:改之前的样子还要留着比对工具绑定有没有被改掉 for n in payload.base_graph["nodes"]: - nodes[n["id"]] = n + nodes[n["id"]] = copy.deepcopy(n) edges = [ {k: v for k, v in e.items() if k in ("source", "target", "sourceHandle")} for e in payload.base_graph.get("edges") or [] @@ -889,6 +1219,9 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g ("human", _with_history(user, await _history_section(session, payload.conversation_id)))] # 兜底用:模型只发了改动操作时,把它们重放到这张图上 prev_graph = await _previous_graph(session, payload.conversation_id) + # 比对工具绑定的基准:画布上是改之前的图;问数据页是上一轮的图(沿用它的节点 id) + baseline = copy.deepcopy( + (payload.base_graph or {}).get("nodes") or (prev_graph or {}).get("nodes") or []) async def event_stream(): explanation = "" @@ -939,7 +1272,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g if not nodes and prev_graph and seen_ops: for n in prev_graph.get("nodes") or []: if n.get("id"): - nodes[n["id"]] = n + nodes[n["id"]] = copy.deepcopy(n) # 原地改,不能重新绑定:给 edges 赋值会让它变成 event_stream 的局部 # 变量,而它在上面 _apply_op 那一路已经被读过了 —— UnboundLocalError edges[:] = [ @@ -954,11 +1287,10 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g # 循环 / 分支条件里套了 {{ }}),前面的步骤白跑,报错还停在半路 repaired = 0 for round_no in range(1, _SELF_CHECK_ROUNDS + 1): - errors = _blocking_issues(nodes, edges) + errors = _blocking_issues(nodes, edges, scope) if not errors: break - yield _sse({"op": "check", "status": "repairing", "round": round_no, - "issues": [_issue_line(i) for i in errors]}) + yield _sse({"op": "check", "status": "repairing", "round": round_no, "issues": errors}) fix = [("system", system), ("human", _repair_request(nodes, edges, errors))] try: async for op in _with_heartbeat(_iter_ops(model, fix), phase="repairing"): @@ -973,15 +1305,20 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g "message": f"自查修正没跑成:{explain_error(e)[0]}", "detail": raw_error(e)}) break repaired = round_no - remaining = _blocking_issues(nodes, edges) + remaining = _blocking_issues(nodes, edges, scope) + # 工具绑定变化不挡运行,但要在自查这一步就说出来:工具被改没了的图照样 + # 能跑,只是跑出来的是模型「假设」查过库的答案 + changes = tool_changes(baseline, list(nodes.values())) + warnings = dropped_tool_warnings(changes, payload.instruction) if remaining: # 改不好也照实交付、列出问题,但不自动运行:明知跑不起来的图,开跑只会 # 在半路报一个用户看不懂的错 autorun = False - yield _sse({"op": "check", "status": "failed", - "issues": [_issue_line(i) for i in remaining]}) + yield _sse({"op": "check", "status": "failed", "issues": remaining, + "warnings": warnings}) elif remaining is not None: - yield _sse({"op": "check", "status": "passed", "repaired": repaired}) + yield _sse({"op": "check", "status": "passed", "repaired": repaired, + "warnings": warnings}) # 收尾:排版 + 校验,把最终图整体交付。改图时旧节点留在原处,只排新节点 try: @@ -989,6 +1326,8 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g GraphSpec.model_validate({"nodes": list(nodes.values()), "edges": edges}), pinned, ) issues = [i.model_dump() for i in validate_graph(spec).issues] + issues += _issue_dicts(scope_issues(list(nodes.values()), scope), + "datasource_out_of_scope") # 跳过的节点要说出来,不能安静地少一步。code 给前端认:这一类要单独 # 提示「少了一步」,不能和普通校验警告混在一起 issues += [ @@ -1000,10 +1339,12 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g final = { "op": "final", "graph": spec.model_dump(mode="json"), - "issues": issues, + "issues": issues + warnings, "explanation": explanation, "autorun": autorun, "layout": layout, + # 改图回执:哪些节点、成员的工具变了。画布上看不出来,得明说 + "tool_changes": changes, } except Exception as e: # noqa: BLE001 final = {"op": "error", "message": f"模型交回的工作流结构不对:{graph_error(e)}", diff --git a/backend/app/api/datasources.py b/backend/app/api/datasources.py index 4e5399d6..ff5c8c60 100644 --- a/backend/app/api/datasources.py +++ b/backend/app/api/datasources.py @@ -8,16 +8,20 @@ from __future__ import annotations from datetime import datetime, timezone from typing import Any -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.errors import explain, first_line, raw +from app.api import health +from app.core.errors import explain, first_line, raw from app.core.config import settings from app.core.crypto import encrypt, mask from app.data import introspect as introspect_mod -from app.data.engine import SUPPORTED_KINDS, build_url, engine_args, engines +from app.data.engine import ( + MAX_QUERY_TIMEOUT_S, QUERY_TIMEOUT_OPTION, SUPPORTED_KINDS, build_url, engine_args, engines, + query_timeout_problem, +) from app.db.base import get_session from app.db.models import DataSource from app.tools.datasource import tool_names @@ -79,12 +83,27 @@ class DataSourceOut(BaseModel): #: 这台服务器上还有哪些库/schema 可选。探不到表时它就是下一步的线索 available_schemas: list[str] = Field(default_factory=list) tools: list[str] = Field(default_factory=list) + #: 缓存是按哪个 schema 探的:"" 是默认 schema,None 是没有缓存(或老缓存没记)。 + #: 和 options.schema 对不上时,助手看到的结构不是配置里那个 schema 的 + cached_schema: str | None = None + #: 最近一次测连接(见 app/api/health.py)。没测过、或者连接配置改过之后都是 None + last_checked_at: str | None = None + last_check_ok: bool | None = None + last_latency_ms: int | None = None + last_error: str | None = None + + +def _cached_schema(cache: dict[str, Any]) -> str | None: + if not cache or "schema" not in cache: + return None + return str(cache.get("schema") or "") def _to_out(row: DataSource) -> DataSourceOut: cache = row.schema_cache or {} tables = cache.get("tables") or {} return DataSourceOut( + cached_schema=_cached_schema(cache), schema_error=str(cache.get("error") or "") if cache.get("failed") else "", available_schemas=list(cache.get("available_schemas") or []), id=row.id, name=row.name, kind=row.kind, host=row.host, port=row.port, @@ -95,9 +114,27 @@ def _to_out(row: DataSource) -> DataSourceOut: table_count=len(tables), schema_synced_at=row.schema_synced_at.isoformat() if row.schema_synced_at else None, tools=tool_names(row), + **health.fields(row.last_check), ) +def _connection(source: Any) -> str | None: + """连接配置的指纹:驱动实际拿到的连接串和参数。配置拼不出连接串时为 None。 + + 按连接串比而不是按字段比:Oracle 的服务名填在「数据库」还是 options 里, + 连的是同一个库;探查用的 schema 不进连接串,改它也不影响连不连得上。 + """ + try: + url, extra = engine_args(source) + except Exception: # noqa: BLE001 - 拼不出来就谈不上「测的是同一份」 + return None + return health.fingerprint("datasource", url, extra) + + +def _probe_record(result: dict[str, Any]) -> dict[str, Any]: + return health.record(result.get("ok"), result.get("elapsed_ms"), result.get("error")) + + async def _get_or_404(session: AsyncSession, source_id: str) -> DataSource: row = await session.get(DataSource, source_id) if not row: @@ -111,6 +148,19 @@ async def list_sources(session: AsyncSession = Depends(get_session)) -> list[Dat return [_to_out(r) for r in rows] +#: 每种库都有的「查询时限」。数据库按它自己停下语句,不只是后端不再等 +_TIMEOUT_FIELD = { + "key": QUERY_TIMEOUT_OPTION, "label": "查询时限(秒)", "placeholder": "30", + "help": f"一条查询最多跑多久,到点由数据库自己停下;留空是 30 秒,最多 {MAX_QUERY_TIMEOUT_S} 秒", +} + + +def _refuse_bad_options(options: dict[str, Any] | None) -> None: + problem = query_timeout_problem(options) + if problem: + raise HTTPException(422, problem) + + @router.get("/kinds") async def list_kinds() -> dict[str, Any]: """支持哪些数据库,以及各自需要填什么——前端表单据此渲染。 @@ -124,11 +174,12 @@ async def list_kinds() -> dict[str, Any]: {"value": "mysql", "label": "MySQL / MariaDB", "default_port": 3306, "needs": ["host", "database", "username", "password"], "hint": "字符集默认 utf8mb4,要换就在「高级连接参数」里加 charset", - "advanced": [{"key": "charset", "label": "字符集", "placeholder": "utf8mb4"}]}, + "advanced": [{"key": "charset", "label": "字符集", "placeholder": "utf8mb4"}, + _TIMEOUT_FIELD]}, {"value": "postgres", "label": "PostgreSQL", "default_port": 5432, "needs": ["host", "database", "username", "password"], "hint": "「schema」留空就用 public", - "advanced": []}, + "advanced": [_TIMEOUT_FIELD]}, {"value": "oracle", "label": "Oracle", "default_port": 1521, "needs": ["host", "username", "password"], "hint": "service_name 和 SID 二选一:一般填 service_name,老库只给了 SID 就切到 SID。" @@ -139,10 +190,11 @@ async def list_kinds() -> dict[str, Any]: {"key": "service_name", "label": "service_name", "placeholder": "ORCLPDB1", "help": "和 SID 二选一"}, {"key": "sid", "label": "SID", "placeholder": "ORCL", "help": "和 service_name 二选一"}, + _TIMEOUT_FIELD, ]}, {"value": "sqlite", "label": "SQLite(文件)", "default_port": None, "needs": ["database"], "hint": "「数据库文件路径」填 .db 文件的绝对路径", - "advanced": []}, + "advanced": [_TIMEOUT_FIELD]}, ], "supported": list(SUPPORTED_KINDS), } @@ -189,12 +241,15 @@ async def create_source( )).scalar_one_or_none() if exists: raise HTTPException(409, f"已经有叫「{payload.name}」的数据源了,换个标识") + _refuse_bad_options(payload.options) row = DataSource( **payload.model_dump(exclude={"password"}), password=encrypt(payload.password), ) _settle_oracle(row, None, None) + # 表单里刚测过这份配置:保存下来就带着那次结果,不必再测一遍 + row.last_check = health.draft_result(_connection(row)) session.add(row) await session.commit() await session.refresh(row) @@ -207,13 +262,20 @@ async def update_source( ) -> DataSourceOut: row = await _get_or_404(session, source_id) data = payload.model_dump(exclude_unset=True) + if "options" in data: + _refuse_bad_options(data["options"]) if "password" in data: # 空串表示清空,非空表示换新的 data["password"] = encrypt(data["password"]) if data["password"] else None old_options, old_database = dict(row.options or {}), row.database + before = _connection(row) for key, value in data.items(): setattr(row, key, value) _settle_oracle(row, old_options, old_database) + after = _connection(row) + if after != before: + # 连的已经不是测过的那个了:旧结果作废,表单里测过这一份的话换成那一次 + row.last_check = health.draft_result(after) await session.commit() await session.refresh(row) # 连接参数可能变了,旧连接池不能再用——否则改完密码还在用旧连接,很难排查 @@ -386,27 +448,72 @@ async def test_draft( return {"ok": False, "error": f"不支持「{payload.kind}」这种数据库", "hint": f"支持:{'、'.join(SUPPORTED_KINDS)}", "detail": "", "elapsed_ms": 0, "url": ""} saved = await session.get(DataSource, payload.id) if payload.id else None - return await _probe(_draft_source(payload, saved), cached=False) + draft = _draft_source(payload, saved) + result = await _probe(draft, cached=False) + last, key = _probe_record(result), _connection(draft) + health.remember_draft(key, last) + if saved is not None and key is not None and key == _connection(saved): + # 编辑框里没改连接、只是再测一次:测的就是已保存的那份 + saved.last_check = last + await session.commit() + return result @router.post("/{source_id}/test") async def test_source(source_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]: """测连接。失败时说清原因和怎么办,驱动的原始报错放在 detail——这类问题九成靠它定位。""" row = await _get_or_404(session, source_id) - return await _probe(row, cached=True) + result = await _probe(row, cached=True) + row.last_check = _probe_record(result) + await session.commit() + return result -@router.post("/{source_id}/introspect", response_model=DataSourceOut) +class IntrospectPreview(BaseModel): + """只看不存的探查结果。缓存、同步时间都没动。""" + + dry_run: bool = True + #: 这次探的是哪个 schema,"" 是默认 schema + schema_: str = Field(default="", serialization_alias="schema") + table_count: int = 0 + #: 带 schema 前缀的对象全名,最多 200 个(和真探查的上限一样) + tables: list[str] = Field(default_factory=list) + truncated: bool = False + total: int = 0 + schema_error: str = "" + available_schemas: list[str] = Field(default_factory=list) + + +@router.post("/{source_id}/introspect", response_model=DataSourceOut | IntrospectPreview) async def introspect_source( - source_id: str, schema: str | None = None, session: AsyncSession = Depends(get_session) -) -> DataSourceOut: - """探查结构并缓存。显式动作,不做后台轮询——生产库不该被实验工具定时扫。""" + source_id: str, + schema: str | None = None, + dry_run: bool = Query(default=False, description="只返回探到的结构,不写缓存"), + session: AsyncSession = Depends(get_session), +) -> DataSourceOut | IntrospectPreview: + """探查结构并缓存。显式动作,不做后台轮询——生产库不该被实验工具定时扫。 + + dry_run=true 只看不存:「换个 schema 看看」以前一探就把缓存换掉,用户随后 + 选了「不改」,助手此刻看到的也已经是另一套表了。 + """ row = await _get_or_404(session, source_id) try: - row.schema_cache = await introspect_mod.introspect(row, schema=schema) + cache = await introspect_mod.introspect(row, schema=schema) except Exception as e: # noqa: BLE001 reason, hint = _explain_connect(e, (row.kind or "").lower()) raise HTTPException(400, f"探查表结构失败:{reason}" + (f"。{hint}" if hint else "")) from e + if dry_run: + tables = cache.get("tables") or {} + return IntrospectPreview( + schema_=str(cache.get("schema") or ""), + table_count=len(tables), + tables=[m.get("qualified", n) for n, m in tables.items()], + truncated=bool(cache.get("truncated")), + total=int(cache.get("total") or 0), + schema_error=str(cache.get("error") or "") if cache.get("failed") else "", + available_schemas=list(cache.get("available_schemas") or []), + ) + row.schema_cache = cache # 失败也要把缓存存下来——里面的 available_schemas 正是下一步的线索—— # 但**不能盖上"已同步"的戳**。以前盖了,于是界面显示同步过、工具却说 # "还没探查过",两边都不说真话(run 554a0f92) @@ -422,7 +529,17 @@ async def get_schema(source_id: str, table: str | None = None, session: AsyncSession = Depends(get_session)) -> dict[str, Any]: row = await _get_or_404(session, source_id) if table: - return {"table": table, "detail": introspect_mod.describe_table(row, table)} + meta = introspect_mod.find_table(row, table) + return { + "table": table, + # 给模型看的那段文本照旧;界面画表格用下面的结构化字段 + "detail": introspect_mod.describe_table(row, table), + "found": meta is not None, + "qualified": meta.get("qualified", table) if meta else None, + "kind": ("view" if meta.get("is_view") else "table") if meta else None, + "comment": meta.get("comment") if meta else None, + "columns": introspect_mod.table_columns(meta) if meta else [], + } return { "tables": introspect_mod.table_names(row), "summary": introspect_mod.summary(row), diff --git a/backend/app/api/errors.py b/backend/app/api/errors.py deleted file mode 100644 index ea996e7b..00000000 --- a/backend/app/api/errors.py +++ /dev/null @@ -1,139 +0,0 @@ -"""面向用户的报错:说清发生了什么、为什么、怎么办,不把 Python 异常类名端上去。 - -以前各处都是 f"{type(e).__name__}: {e}",界面首行红字是 -「_make_query_tool.._run() got an unexpected keyword argument 'query'」 -——对业务用户是天书,也没有下一步。原始异常不丢:调用方把 raw(e) 放进响应的 -detail 字段或写进日志,排查时照样找得到。 - -判断按类名走 MRO,不 import 各家 SDK:没装的驱动和 SDK 不该让报错这一层 -也跟着 ImportError。 -""" -from __future__ import annotations - -import re -from typing import Any - -_TIMEOUT = {"TimeoutError", "TimeoutException", "APITimeoutError", "ReadTimeout", - "ConnectTimeout", "PoolTimeout", "WriteTimeout"} -_NETWORK = {"ConnectError", "ConnectionError", "APIConnectionError", "gaierror", - "ClientConnectorError", "RemoteProtocolError"} -_AUTH = {"AuthenticationError", "PermissionDeniedError", "InvalidPasswordError", - "InvalidAuthorizationSpecificationError"} -#: 这几类是程序自己的毛病,str(e) 里全是内部符号,原样端上去只会更糊涂 -_BUG = {"TypeError", "AttributeError", "KeyError", "NameError", "IndexError", - "UnboundLocalError", "AssertionError", "RecursionError"} - -#: SQLAlchemy 包过的驱动异常开头是「(sqlite3.OperationalError) 」 -_WRAPPED = re.compile(r"^\([\w.]+\)\s*") - - -def raw(e: BaseException) -> str: - """原始报错,给「技术细节」折叠区和日志用。""" - return f"{type(e).__name__}: {e}" - - -def _status(e: BaseException) -> int | None: - for obj in (e, getattr(e, "response", None)): - code = getattr(obj, "status_code", None) - if isinstance(code, int): - return code - return None - - -def first_line(e: BaseException) -> str: - """异常自己的说明,去掉驱动包装的类名前缀和 SQLAlchemy 的文档链接。""" - text = str(e).strip() - line = text.splitlines()[0] if text else "" - return _WRAPPED.sub("", line)[:300] - - -def explain(e: BaseException) -> tuple[str, str]: - """(原因, 怎么办)。原因是一句人话;说不出怎么办时第二项是空串。""" - names = {c.__name__ for c in type(e).__mro__} - low = str(e).lower() - status = _status(e) - - if status in (401, 403) or names & _AUTH or "invalid api key" in low or "unauthorized" in low: - return "对方拒绝了这把密钥", "核对 API Key 有没有填错、过期,或者这个账号有没有权限" - if status == 404 or "NotFoundError" in names: - return "对方说找不到", "核对地址的路径(常见的是少了或多了 /v1)和模型名" - if status == 429 or "RateLimitError" in names: - return "请求被对方限流了", "等一会儿再试;频繁出现就检查账号额度" - if isinstance(status, int) and status >= 500: - return f"对方服务出错了(HTTP {status})", "这是对方的问题,稍后再试" - if names & _TIMEOUT or "timed out" in low: - return "等了太久没有响应", "对方可能很忙或者网络不稳,稍后再试;一直超时就检查地址和网络" - if names & _NETWORK or any(s in low for s in ( - "connection refused", "nodename nor servname", "name or service not known", - "all connection attempts failed", "network is unreachable", - )): - return "连不上对方的服务", "核对地址和端口,确认服务已经启动、这台机器访问得到它" - if "JSONDecodeError" in names: - return "对方返回的不是 JSON", "地址多半指到了网页而不是 API,核对 Base URL" - if "ValidationError" in names and callable(getattr(e, "errors", None)): - return f"参数不对:{_first_validation(e)}", "" - if names & _BUG: - return "后端内部出错了", "这不是你操作的问题;把技术细节发给维护者" - return first_line(e) or "出了一个没有说明的错误", "" - - -def message(e: BaseException, lead: str = "") -> str: - """一整句:lead + 原因 + 怎么办。给只能放一个字符串的地方(HTTPException 的 detail)。""" - reason, hint = explain(e) - text = f"{lead}{reason}" if lead else reason - return f"{text}。{hint}" if hint else text - - -def payload(e: BaseException, lead: str = "") -> dict[str, Any]: - """{ok: false, error, hint, detail}:测试连接这类「失败也是正常结果」的接口用。""" - reason, hint = explain(e) - return {"ok": False, "error": f"{lead}{reason}", "hint": hint, "detail": raw(e)} - - -def not_configured(e: BaseException) -> str: - """模型接入没配好时的原话用的是内部叫法(provider 'x'、base_url),换成设置页上的说法。""" - text = re.sub(r"provider '([^']*)'", r"模型接入「\1」", str(e)) - text = re.sub(r"模型 '([^']*)'", r"模型「\1」", text) - return text.replace("base_url", "Base URL").rstrip("。") - - -def _has_cjk(text: str) -> bool: - return any("一" <= ch <= "鿿" for ch in text) - - -def _first_validation(e: Any) -> str: - err = (e.errors() or [{}])[0] - loc = ".".join(str(p) for p in err.get("loc") or ()) - msg = str(err.get("msg") or "").removeprefix("Value error, ") - if _has_cjk(msg): - return msg - return f"「{loc}」{'缺了' if err.get('type') == 'missing' else '格式不对'}" if loc else "格式不对" - - -def graph_error(e: BaseException) -> str: - """工作流结构读不懂时,指到是哪个节点、哪一项。 - - pydantic 的原文是一大段英文,还列了全部十几种节点类型和一个文档链接, - 用户从里面找不到自己错在哪。 - """ - errors = e.errors() if callable(getattr(e, "errors", None)) else None - if not errors: - return explain(e)[0] - err = errors[0] - loc = list(err.get("loc") or ()) - msg = str(err.get("msg") or "").removeprefix("Value error, ") - more = f"(另有 {len(errors) - 1} 处)" if len(errors) > 1 else "" - - if len(loc) >= 2 and loc[0] in ("nodes", "edges") and isinstance(loc[1], int): - where = f"第 {loc[1] + 1} 个{'节点' if loc[0] == 'nodes' else '连线'}" - field = str(loc[2]) if len(loc) > 2 else "" - if err.get("type") == "enum" and field == "type": - return f"{where}的类型「{err.get('input')}」不认识{more}" - if err.get("type") == "missing": - return f"{where}缺了「{field}」{more}" - if _has_cjk(msg): - return f"{where}:{msg}{more}" - return f"{where}的「{field or '内容'}」格式不对{more}" - if _has_cjk(msg): - return msg + more - return f"「{'.'.join(str(p) for p in loc) or '整体'}」格式不对{more}" diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 00000000..8df5c0a2 --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,69 @@ +"""最近一次测连接的结果:数据源、模型接入、MCP 服务共用的一套记法。 + +以前这份结果只在浏览器的 localStorage 里,换台电脑就全回到「未测试」。现在记在 +对象上(数据源、MCP 是 last_check 列,模型接入是 extra.last_check),列表接口 +平铺成 last_checked_at / last_check_ok / last_latency_ms / last_error 四个字段。 + +表单里「先测再保存」的那一次也要算数:测的是草稿,保存时后端按连接配置的指纹 +认出「刚测过的就是这一份」,把结果带到新对象上。指纹只在进程里记一小会儿, +用的是哈希,不留明文密码。 +""" +from __future__ import annotations + +import hashlib +import json +import time +from typing import Any + +from app.db.base import utcnow + +#: 草稿测完多久之内保存还算数。测完去倒杯水回来再点保存,也该认得出来 +_DRAFT_TTL = 15 * 60 +_DRAFT_MAX = 64 +_drafts: dict[str, tuple[float, dict[str, Any]]] = {} + + +def record(ok: bool, latency_ms: int | None, error: str | None = None) -> dict[str, Any]: + return { + "at": utcnow().isoformat(), + "ok": bool(ok), + "latency_ms": None if latency_ms is None else int(latency_ms), + "error": None if ok else (error or "连不上"), + } + + +def fields(last: dict[str, Any] | None) -> dict[str, Any]: + """对象上记的那份 → 列表接口的四个字段。没测过全是 None。""" + last = last if isinstance(last, dict) else {} + ok = last.get("ok") + return { + "last_checked_at": last.get("at"), + "last_check_ok": ok if isinstance(ok, bool) else None, + "last_latency_ms": last.get("latency_ms"), + "last_error": last.get("error"), + } + + +def fingerprint(*parts: Any) -> str: + """连接配置的指纹。同一份配置得出同一个值;只拿来比对,不可逆。""" + blob = json.dumps(parts, sort_keys=True, ensure_ascii=False, default=str) + return hashlib.sha256(blob.encode()).hexdigest() + + +def remember_draft(key: str | None, last: dict[str, Any]) -> None: + if not key: + return + now = time.monotonic() + for stale in [k for k, (at, _) in _drafts.items() if now - at > _DRAFT_TTL]: + _drafts.pop(stale, None) + if len(_drafts) >= _DRAFT_MAX: + _drafts.pop(min(_drafts, key=lambda k: _drafts[k][0]), None) + _drafts[key] = (now, last) + + +def draft_result(key: str | None) -> dict[str, Any] | None: + """这份配置最近在表单里测过的结果,没有或太久了返回 None。""" + hit = _drafts.get(key or "") + if not hit or time.monotonic() - hit[0] > _DRAFT_TTL: + return None + return dict(hit[1]) diff --git a/backend/app/api/knowledge.py b/backend/app/api/knowledge.py index 02e4a2c8..3a609877 100644 --- a/backend/app/api/knowledge.py +++ b/backend/app/api/knowledge.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.errors import explain, raw +from app.core.errors import explain, raw from app.core.config import settings from app.db.base import get_session from app.db.models import Document, MemoryItem, Skill @@ -433,20 +433,129 @@ async def probe_embedding(base_url: str) -> dict[str, Any]: return {"base_url": base_url, "models": models} +class _ReindexJob: + """一次重建索引。进度按批更新:kb / store 每提交一批回调一次,不插值。""" + + def __init__(self, collection: str | None, chunks: int, memories: int) -> None: + from app.db.base import new_id, utcnow + + self.id = new_id() + self.collection = collection + self.state = "running" # running | done | failed + # chunks → index(倒排重建,没有分批进度)→ memories → done + self.phase = "chunks" + self.chunks = {"total": chunks, "done": 0} + self.memories = {"total": memories, "done": 0} + self.started_at = utcnow() + self.finished_at: Any = None + self.error: str | None = None + self.hint: str | None = None + self.result: dict[str, Any] | None = None + + def chunk_step(self, done: int, total: int) -> None: + self.chunks = {"total": total, "done": done} + if done >= total: + self.phase = "index" + + def memory_step(self, done: int, total: int) -> None: + self.memories = {"total": total, "done": done} + + def view(self) -> dict[str, Any]: + return { + "id": self.id, "state": self.state, "phase": self.phase, + "collection": self.collection, + "total": self.chunks["total"] + self.memories["total"], + "done": self.chunks["done"] + self.memories["done"], + "chunks": dict(self.chunks), "memories": dict(self.memories), + "started_at": self.started_at.isoformat(), + "finished_at": self.finished_at.isoformat() if self.finished_at else None, + "error": self.error, "hint": self.hint, "result": self.result, + } + + +#: 最近一次重建。只在进程里记:重建是幂等的,服务重启丢了进度,再点一次就是 +_REINDEX: _ReindexJob | None = None +#: 后台任务要有人拿着引用,否则跑到一半可能被回收 +_REINDEX_TASK: Any = None + + +def reindex_job() -> _ReindexJob | None: + return _REINDEX + + +async def _run_reindex(job: _ReindexJob) -> None: + from app.db.base import SessionLocal, utcnow + + try: + async with SessionLocal() as session: + out = await kb.reindex(session, job.collection, on_progress=job.chunk_step) + job.phase = "memories" + # 记忆不按 collection 分,重建就是全量 + out["memories_reindexed"] = await store.reindex(session, on_progress=job.memory_step) + job.result, job.state, job.phase = out, "done", "done" + except Exception as e: # noqa: BLE001 - 失败要落到任务状态上,不能只进日志 + logger.warning("重建索引失败:%s", raw(e)) + reason, hint = explain(e) + job.state, job.error, job.hint = "failed", f"重建索引没做完:{reason}", hint or None + finally: + job.finished_at = utcnow() + + @kb_router.post("/reindex") async def reindex( - collection: str | None = None, session: AsyncSession = Depends(get_session) -) -> dict[str, Any]: + collection: str | None = None, + background: bool = Query(default=False, description="后台跑,立刻返回任务;进度用 GET /kb/reindex 查"), + session: AsyncSession = Depends(get_session), +): """用当前 embedder 重算向量。换了模型之后唯一的恢复手段。 知识库和长期记忆一起重建:它们共用一个 embedder,换模型时一起失效。 分成两个按钮的结果是点了一个、以为好了,另一个还在悄悄退回关键词—— 真踩过,记忆那边连个提示都没有。 + + 几千段配远端 embedding 要好几分钟,压在请求里界面只能干转圈。background=true + 时立刻返回 202 和任务,按批更新进度;不带它仍然等做完再返回,老调用方不受影响。 + 同一时间只跑一个:两次重建交错写同一批向量,谁最后写赢都说不清。 """ - out = await kb.reindex(session, collection) - # 记忆不按 collection 分,重建就是全量 - out["memories_reindexed"] = await store.reindex(session) - return out + global _REINDEX, _REINDEX_TASK + import asyncio + + from fastapi.responses import JSONResponse + from sqlalchemy import func + + from app.db.models import Chunk + + count = select(func.count(Chunk.id)) + if collection: + count = count.where(Chunk.collection == collection) + chunks = int((await session.execute(count)).scalar_one() or 0) + memories = int((await session.execute(select(func.count(MemoryItem.id)))).scalar_one() or 0) + # 先数完再查「有没有在跑」,查和占位之间不能有 await:以前先查后数,两个同时 + # 到的请求都在计数那里让出去,回来都以为没在跑,于是两个都起了 + if _REINDEX is not None and _REINDEX.state == "running": + running = _REINDEX.view() + raise HTTPException( + 409, f"已经在重建索引了({_REINDEX.collection or '全部集合'}," + f"已完成 {running['done']}/{running['total']}),等它做完再点", + ) + job = _REINDEX = _ReindexJob(collection, chunks, memories) + if background: + _REINDEX_TASK = asyncio.create_task(_run_reindex(job)) + return JSONResponse(job.view(), status_code=202) + + await _run_reindex(job) + if job.state == "failed": + raise HTTPException(502, job.error + (f"。{job.hint}" if job.hint else "")) + return job.result + + +@kb_router.get("/reindex") +async def reindex_status() -> dict[str, Any]: + """最近一次重建的进度:{state, phase, total, done, chunks, memories, result, error…}。 + + 还没重建过返回 {state: "idle"}。 + """ + return _REINDEX.view() if _REINDEX is not None else {"state": "idle"} #: 单段返回多少字。切块本来就在千字量级,详情页是给人扫一眼"切得对不对"的, diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index 1e79b05d..a24c31c0 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -172,9 +172,14 @@ async def start_run( return run +#: workflow_id 取这个值时只要没挂在任何工作流上的运行:画布上没保存就跑的临时图, +#: 以及工作流被删掉之后留下来的运行(外键是 SET NULL) +UNSAVED = "__none__" + + @router.get("", response_model=list[RunOut]) async def list_runs( - workflow_id: str | None = None, + workflow_id: str | None = Query(default=None, description=f"{UNSAVED} 表示没保存的工作流"), status: str | None = Query(default=None, description="逗号分隔,如 failed,cancelled"), run_class: str | None = None, q: str | None = Query(default=None, description="按工作流名模糊匹配"), @@ -188,7 +193,9 @@ async def list_runs( 筛选都走服务端,翻页用 created_at 做游标(before 传上一页最后一条的 created_at)。 """ stmt = select(Run).order_by(Run.created_at.desc()).limit(limit) - if workflow_id: + if workflow_id == UNSAVED: + stmt = stmt.where(Run.workflow_id.is_(None)) + elif workflow_id: stmt = stmt.where(Run.workflow_id == workflow_id) if statuses := _csv(status): stmt = stmt.where(Run.status.in_(statuses)) @@ -283,13 +290,25 @@ async def list_artifacts( @router.post("/{run_id}/cancel") -async def cancel_run(run_id: str) -> dict[str, Any]: - stopped = await run_manager.cancel(run_id) - if not stopped: - raise HTTPException( - 409, "这次运行已经不在执行中(可能刚结束,或者服务重启过),不需要再停止。刷新看看最新状态" - ) - return {"ok": True} +async def cancel_run(run_id: str, x_actor: str | None = Header(default=None)) -> dict[str, Any]: + """停止一次运行。status 是 stopping(正在执行的,取消已发出,稍后收到 run.cancelled) + 或 cancelled(停在审批上、或服务重启挂起的,已经收成终态,待审批一并关闭)。""" + outcome = await run_manager.cancel(run_id, actor=actor_of(x_actor)) + if outcome is None: + async with SessionLocal() as session: + run = await session.get(Run, run_id) + if run is None: + raise HTTPException(404, "运行记录不存在") + raise HTTPException(409, _cannot_cancel(run.status)) + return {"ok": True, "status": outcome} + + +def _cannot_cancel(status: str) -> str: + return { + "succeeded": "这次运行已完成,没有要停止的。", + "failed": "这次运行已经失败结束了,没有要停止的;要继续请用「接着跑」。", + "cancelled": "这次运行已经取消了。", + }.get(status, "这次运行已经不在执行中(可能刚结束,或者服务重启过),不需要再停止。刷新看看最新状态") class ResumeIn(BaseModel): diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index a921609d..121632cb 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -8,7 +8,8 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.errors import explain, not_configured, raw +from app.api import health +from app.core.errors import explain, not_configured, raw from app.core.config import settings as app_settings from app.core.crypto import encrypt, mask from app.db.base import get_session @@ -57,9 +58,49 @@ class ProviderOut(BaseModel): # 只回掩码,明文 key 永远不出后端 api_key_masked: str = "" has_key: bool = False + #: 最近一次测连接(见 app/api/health.py)。没测过、或者连接配置改过之后都是 None + last_checked_at: str | None = None + last_check_ok: bool | None = None + last_latency_ms: int | None = None + last_error: str | None = None + + +#: extra 里这一项是后端记的最近一次测连接结果,不是用户填的配置:不回显、 +#: 不接受客户端写入,保存 extra 时原样留着 +LAST_CHECK = "last_check" + + +def _user_extra(extra: dict[str, Any] | None) -> dict[str, Any]: + return {k: v for k, v in (extra or {}).items() if k != LAST_CHECK} + + +def _with_saved_headers(extra: dict[str, Any], saved: dict[str, Any] | None) -> dict[str, Any]: + """请求头的值回显的是 ***:原样带回来的,换回存着的真值。 + + 设置页保存时会把读到的 extra 整块带回来。以前照单全收,于是改个名字、 + 勾一下启用,自定义请求头就全变成了字面上的 ***,接口从此 401。 + """ + headers = extra.get("headers") + if not isinstance(headers, dict): + return extra + old = (saved or {}).get("headers") or {} + return {**extra, "headers": {k: (old.get(k, v) if v == "***" else v) for k, v in headers.items()}} + + +def _connection(provider: Provider, model: str | None = None) -> str: + """连接配置的指纹:类型、地址、实际用的钥匙、请求头这些、测的哪个模型。""" + return health.fingerprint( + "provider", provider.kind, (provider.base_url or "").strip(), _resolve_key(provider) or "", + _user_extra(provider.extra), model or provider.default_model or "", + ) + + +def _set_last_check(row: Provider, last: dict[str, Any] | None) -> None: + row.extra = {**_user_extra(row.extra), **({LAST_CHECK: last} if last else {})} def _to_out(row: Provider) -> ProviderOut: + extra = _user_extra(row.extra) return ProviderOut( id=row.id, name=row.name, @@ -68,13 +109,14 @@ def _to_out(row: Provider) -> ProviderOut: default_model=row.default_model, models=row.models or [], enabled=row.enabled, - extra={k: v for k, v in (row.extra or {}).items() if k != "headers"} | ( - {"headers": {k: "***" for k in (row.extra or {}).get("headers", {})}} - if (row.extra or {}).get("headers") + extra={k: v for k, v in extra.items() if k != "headers"} | ( + {"headers": {k: "***" for k in extra.get("headers", {})}} + if extra.get("headers") else {} ), api_key_masked=mask(row.api_key), has_key=bool(row.api_key), + **health.fields((row.extra or {}).get(LAST_CHECK)), ) @@ -120,8 +162,10 @@ async def create_provider( models=payload.models, default_model=payload.default_model, enabled=payload.enabled, - extra=payload.extra, + extra=_user_extra(payload.extra), ) + # 表单里刚测过这份配置:保存下来就带着那次结果 + _set_last_check(row, health.draft_result(_connection(row))) session.add(row) await session.commit() await session.refresh(row) @@ -137,11 +181,21 @@ async def update_provider( raise HTTPException(404, "这个模型接入不存在,可能已经被删了") data = payload.model_dump(exclude_unset=True) + before, last = _connection(row), (row.extra or {}).get(LAST_CHECK) if "api_key" in data: row.api_key = encrypt(data.pop("api_key")) if data["api_key"] else None data.pop("api_key", None) + if data.get("extra") is not None: + data["extra"] = _with_saved_headers(_user_extra(data["extra"]), row.extra) + else: + data.pop("extra", None) for key, value in data.items(): setattr(row, key, value) + after = _connection(row) + if after != before: + # 测过的已经不是这份配置了:旧结果作废,表单里测过这一份的话换成那一次 + last = health.draft_result(after) + _set_last_check(row, last) await session.commit() await session.refresh(row) return _to_out(row) @@ -224,7 +278,16 @@ async def test_provider( row = await session.get(Provider, provider_id) if not row: raise HTTPException(404, "这个模型接入不存在,可能已经被删了") - return await _try_model(row, payload.model, payload.prompt) + result = await _try_model(row, payload.model, payload.prompt) + # 只记默认模型的结果:拿别的模型测出 invalid_model,不等于这个接入连不上 + if (payload.model or row.default_model) == row.default_model: + _set_last_check(row, _test_record(result)) + await session.commit() + return result + + +def _test_record(result: dict[str, Any]) -> dict[str, Any]: + return health.record(result.get("ok"), result.get("latency_ms"), result.get("error")) class ProviderDraftIn(BaseModel): @@ -249,11 +312,9 @@ def _draft_provider(payload: ProviderDraftIn, saved: Provider | None) -> Provide 编辑时 Key 框留空表示不改、请求头回显的是 ***——测试都得换回存着的真值, 否则改个 Base URL 想测一下,只会得到一句"密钥不对"。 """ - extra = dict(payload.extra or {}) - headers = extra.get("headers") - if saved and isinstance(headers, dict): - old = (saved.extra or {}).get("headers") or {} - extra["headers"] = {k: (old.get(k, v) if v == "***" else v) for k, v in headers.items()} + extra = _user_extra(payload.extra) + if saved: + extra = _with_saved_headers(extra, saved.extra) return Provider( name=payload.name or (saved.name if saved else "草稿"), kind=payload.kind, @@ -284,7 +345,16 @@ async def test_provider_draft( problem = _config_problem(payload.kind, payload.base_url) if problem: return {"ok": False, "error": problem, "hint": "", "detail": ""} - return await _try_model(await _draft(payload, session), payload.model, payload.prompt) + saved = await session.get(Provider, payload.id) if payload.id else None + draft = _draft_provider(payload, saved) + result = await _try_model(draft, payload.model, payload.prompt) + last, key = _test_record(result), _connection(draft, payload.model) + health.remember_draft(key, last) + if saved is not None and key == _connection(saved): + # 编辑框里没改连接、只是再测一次:测的就是已保存的那份 + _set_last_check(saved, last) + await session.commit() + return result @router.post("/providers/models") diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 3f182424..21e9ffbc 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -7,13 +7,15 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.errors import explain, raw +from app.api import health +from app.core.errors import explain, raw from app.db.base import get_session from app.db.models import CustomTool, McpServer -from app.tools.custom import run_custom_tool +from app.tools.custom import ToolFailure, schema_problem, trial from app.tools.mcp_manager import mcp_manager from app.tools.registry import ( - ToolArgsError, ToolContext, all_specs, build_tools, call_is_dangerous, call_tool, get_spec, + ToolArgsError, ToolBuildError, ToolContext, all_specs, build_tools, call_is_dangerous, + call_tool, get_spec, ) router = APIRouter(prefix="/api/tools", tags=["tools"]) @@ -55,6 +57,8 @@ async def list_tools(session: AsyncSession = Depends(get_session)) -> list[dict[ # 在补上之前不能让标签说它会被审批 "runtime_approval": False, "schema": row.parameters or {}, + # 这道关卡之前存进库的坏参数定义:绑上它的节点会失败,列表上先标出来 + "problem": schema_problem(row.parameters or {}), } ) @@ -146,12 +150,13 @@ async def run_tool( import time started = time.perf_counter() + notes: list[str] = [] try: - result = await call_tool(tool_name, payload.args, ctx, session=session) + result = await call_tool(tool_name, payload.args, ctx, session=session, on_fix=notes.append) except KeyError as e: raise HTTPException(404, str(e.args[0] if e.args else e)) from e - except ToolArgsError as e: - # 参数对不上时报错里已经写清该填什么,原样给 + except (ToolArgsError, ToolBuildError) as e: + # 参数对不上、工具自己的配置写坏了:报错里已经写清该改什么,原样给 return {"ok": False, "error": str(e), "hint": "", "detail": raw(e), "duration_ms": int((time.perf_counter() - started) * 1000)} except Exception as e: # noqa: BLE001 @@ -167,6 +172,8 @@ async def run_tool( "ok": True, "result": result, "duration_ms": int((time.perf_counter() - started) * 1000), + # 参数名写错、被替换成唯一候选跑通了:运行时会报出来,这里也得说 + **({"note": notes[0]} if notes else {}), } @@ -181,49 +188,69 @@ class CustomToolIn(BaseModel): name: str = Field(min_length=1, max_length=100, pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*$") description: str = "" kind: str = "http" # http | python - parameters: dict[str, Any] = Field(default_factory=dict) + #: JSON Schema。不在这里限定成 dict:写成数组、字符串时要回一句人话(见 _refuse_bad_schema), + #: 而不是 pydantic 的英文错误列表 + parameters: Any = Field(default_factory=dict) config: dict[str, Any] = Field(default_factory=dict) enabled: bool = True class CustomToolOut(CustomToolIn): id: str + parameters: dict[str, Any] = Field(default_factory=dict) + #: 参数定义哪里写坏了(保存时的关卡加上之前存进库的)。绑上它的节点会失败 + problem: str | None = None model_config = {"from_attributes": True} +def _custom_out(row: CustomTool) -> CustomToolOut: + out = CustomToolOut.model_validate(row) + out.problem = schema_problem(row.parameters or {}) + return out + + +def _refuse_bad_schema(parameters: Any) -> None: + """参数定义写坏了就不让存:存进去之后,绑了它的节点在运行时才炸。""" + problem = schema_problem(parameters) + if problem: + raise HTTPException(422, problem) + + @custom_router.get("", response_model=list[CustomToolOut]) -async def list_custom(session: AsyncSession = Depends(get_session)) -> list[CustomTool]: - return list((await session.execute(select(CustomTool))).scalars()) +async def list_custom(session: AsyncSession = Depends(get_session)) -> list[CustomToolOut]: + return [_custom_out(r) for r in (await session.execute(select(CustomTool))).scalars()] @custom_router.post("", response_model=CustomToolOut, status_code=201) async def create_custom( payload: CustomToolIn, session: AsyncSession = Depends(get_session) -) -> CustomTool: +) -> CustomToolOut: if (await session.execute(select(CustomTool).where(CustomTool.name == payload.name))).scalar_one_or_none(): raise HTTPException(409, f"已经有叫「{payload.name}」的工具了,换个名字") if payload.name in all_specs(): raise HTTPException(409, f"「{payload.name}」和内置工具重名了,换个名字") + _refuse_bad_schema(payload.parameters) row = CustomTool(**payload.model_dump()) session.add(row) await session.commit() await session.refresh(row) - return row + return _custom_out(row) @custom_router.patch("/{tool_id}", response_model=CustomToolOut) async def update_custom( tool_id: str, payload: CustomToolIn, session: AsyncSession = Depends(get_session) -) -> CustomTool: +) -> CustomToolOut: row = await session.get(CustomTool, tool_id) if not row: raise HTTPException(404, "这个工具不存在,可能已经被删了") + _refuse_bad_schema(payload.parameters) for key, value in payload.model_dump().items(): setattr(row, key, value) await session.commit() await session.refresh(row) - return row + return _custom_out(row) @custom_router.delete("/{tool_id}", status_code=204) @@ -235,6 +262,84 @@ async def delete_custom(tool_id: str, session: AsyncSession = Depends(get_sessio await session.commit() +async def _trial(row: CustomTool, args: dict[str, Any]) -> dict[str, Any]: + """试跑一次,返回和工具库「执行」同一种形状:{ok, result | error/hint/detail, duration_ms, note?}。""" + import json + import time + + ctx = ToolContext(run_id="playground", node_id="test", sandbox_session="playground") + started = time.perf_counter() + + def took() -> int: + return int((time.perf_counter() - started) * 1000) + + problem = schema_problem(row.parameters or {}) + if problem: + # 手写的参数定义写坏了:是配置的事,不是「后端内部出错」 + return {"ok": False, "error": problem, + "hint": '参数定义是 JSON Schema,写成 {"type": "object", "properties": ' + '{"n": {"type": "integer", "description": "…"}}, "required": ["n"]}', + "detail": "", "duration_ms": 0} + try: + result, note = await trial(row, args, ctx) + except ToolArgsError as e: + # 参数对不上时报错里已经写清该填什么,原样给 + return {"ok": False, "error": str(e), "hint": "", "detail": raw(e), "duration_ms": took()} + except Exception as e: # noqa: BLE001 + reason, hint = explain(e) + return {"ok": False, "error": f"工具执行失败:{reason}", "hint": hint, "detail": raw(e), + "duration_ms": took()} + # 参数名被纠正过:跑通了也要说,用户照着试跑的参数去配工作流 + extra = {"note": note} if note else {} + if isinstance(result, ToolFailure): + detail = str(result.get("error") or "") + if "exit_code" in result: + error = f"代码运行出错(退出码 {result['exit_code']})" + hint = "看下面的报错改代码;调用时传的参数在 args 变量里(一个 dict)" + else: + error = f"工具执行失败:{detail.splitlines()[0] if detail else '没有说明'}" + hint = "HTTP 工具只能访问公网地址,内网、回环地址会被拦截" if "拦截" in detail else "" + return {"ok": False, "error": error, "hint": hint, + "detail": json.dumps(dict(result), ensure_ascii=False), "duration_ms": took(), + **extra} + return {"ok": True, "result": result, "duration_ms": took(), **extra} + + +class CustomToolDraftIn(BaseModel): + """还没保存(或改了还没保存)的一份工具配置,外加这次试跑的参数。""" + + name: str = "draft_tool" + kind: str = "http" + parameters: Any = Field(default_factory=dict) + config: dict[str, Any] = Field(default_factory=dict) + args: dict[str, Any] = Field(default_factory=dict) + + +def _draft_problem(kind: str, config: dict[str, Any]) -> str | None: + if kind not in ("http", "python"): + return f"不认识「{kind}」这种工具类型:只有 http 和 python 两种" + if kind == "http" and not str(config.get("url") or "").strip(): + return "还没填接口地址" + if kind == "python" and not str(config.get("code") or "").strip(): + return "还没写代码" + return None + + +@custom_router.post("/test") +async def test_custom_draft(payload: CustomToolDraftIn) -> dict[str, Any]: + """试跑一份没保存的配置,不落库。 + + 以前只能试已保存的:新建要先保存才能试,改了代码也得先存——保存等于让节点 + 立刻用上一个还没试过的版本。 + """ + problem = _draft_problem(payload.kind, payload.config) + if problem: + return {"ok": False, "error": problem, "hint": "", "detail": "", "duration_ms": 0} + draft = CustomTool(name=payload.name or "draft_tool", kind=payload.kind, + parameters=payload.parameters, config=payload.config) + return await _trial(draft, payload.args) + + @custom_router.post("/{tool_id}/test") async def test_custom( tool_id: str, payload: RunToolIn, session: AsyncSession = Depends(get_session) @@ -242,13 +347,7 @@ async def test_custom( row = await session.get(CustomTool, tool_id) if not row: raise HTTPException(404, "这个工具不存在,可能已经被删了") - ctx = ToolContext(run_id="playground", node_id="test", sandbox_session="playground") - try: - result = await run_custom_tool(session, row.name, payload.args, ctx) - except Exception as e: # noqa: BLE001 - reason, hint = explain(e) - return {"ok": False, "error": f"工具执行失败:{reason}", "hint": hint, "detail": raw(e)} - return {"ok": True, "result": result} + return await _trial(row, payload.args) # -------------------------------------------------------------------------- @@ -273,40 +372,64 @@ class McpOut(McpIn): status: str last_error: str | None tools_cache: list[Any] + #: 最近一次连接的时刻和耗时(见 app/api/health.py)。status 只有结论,没有「何时」 + last_checked_at: str | None = None + last_check_ok: bool | None = None + last_latency_ms: int | None = None model_config = {"from_attributes": True} +def _mcp_out(row: McpServer) -> McpOut: + out = McpOut.model_validate(row) + checked = health.fields(row.last_check) + out.last_checked_at = checked["last_checked_at"] + out.last_check_ok = checked["last_check_ok"] + out.last_latency_ms = checked["last_latency_ms"] + return out + + +def _mcp_connection(row: McpServer) -> tuple[Any, ...]: + return (row.transport, row.command, list(row.args or []), dict(row.env or {}), row.url) + + @mcp_router.get("/servers", response_model=list[McpOut]) -async def list_servers(session: AsyncSession = Depends(get_session)) -> list[McpServer]: - return list((await session.execute(select(McpServer))).scalars()) +async def list_servers(session: AsyncSession = Depends(get_session)) -> list[McpOut]: + return [_mcp_out(r) for r in (await session.execute(select(McpServer))).scalars()] @mcp_router.post("/servers", response_model=McpOut, status_code=201) async def create_server( payload: McpIn, session: AsyncSession = Depends(get_session) -) -> McpServer: +) -> McpOut: if (await session.execute(select(McpServer).where(McpServer.name == payload.name))).scalar_one_or_none(): raise HTTPException(409, f"已经有叫「{payload.name}」的 MCP 服务了,换个名字") row = McpServer(**payload.model_dump()) session.add(row) await session.commit() await session.refresh(row) - return row + return _mcp_out(row) @mcp_router.patch("/servers/{server_id}", response_model=McpOut) async def update_server( server_id: str, payload: McpIn, session: AsyncSession = Depends(get_session) -) -> McpServer: +) -> McpOut: row = await session.get(McpServer, server_id) if not row: raise HTTPException(404, "这个 MCP 服务不存在,可能已经被删了") + before, old_name, was_enabled = _mcp_connection(row), row.name, row.enabled for key, value in payload.model_dump().items(): setattr(row, key, value) + if _mcp_connection(row) != before: + # 启动命令、地址换了,上次的「连得上」说的是另一个进程 + row.status, row.last_error, row.last_check = "unknown", None, None await session.commit() await session.refresh(row) - return row + if (_mcp_connection(row), row.name, row.enabled) != (before, old_name, was_enabled): + # 按旧配置、旧名字建的工具作废,下次用到时按新的连 + mcp_manager.invalidate(old_name, row.name) + return _mcp_out(row) @mcp_router.delete("/servers/{server_id}", status_code=204) @@ -316,6 +439,7 @@ async def delete_server(server_id: str, session: AsyncSession = Depends(get_sess raise HTTPException(404, "这个 MCP 服务不存在,可能已经被删了") await session.delete(row) await session.commit() + mcp_manager.invalidate(row.name) @mcp_router.post("/servers/{server_id}/probe") @@ -325,10 +449,15 @@ async def probe_server( row = await session.get(McpServer, server_id) if not row: raise HTTPException(404, "这个 MCP 服务不存在,可能已经被删了") + import time + + started = time.perf_counter() result = await mcp_manager.probe(row) + result["elapsed_ms"] = int((time.perf_counter() - started) * 1000) row.status = "ok" if result.get("ok") else "error" row.last_error = result.get("error") row.tools_cache = [t["name"] for t in result.get("tools", [])] + row.last_check = health.record(result.get("ok"), result["elapsed_ms"], result.get("error")) await session.commit() return result diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index b800abc7..7d95bd36 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.errors import graph_error +from app.core.errors import graph_error from app.api.runs import actor_of from app.core.artifact_store import graph_hash from app.db.base import get_session diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 00000000..2ed707e9 --- /dev/null +++ b/backend/app/core/errors.py @@ -0,0 +1,211 @@ +"""面向用户的报错:说清发生了什么、为什么、怎么办,不把 Python 异常类名端上去。 + +以前各处都是 f"{type(e).__name__}: {e}",界面首行红字是 +「_make_query_tool.._run() got an unexpected keyword argument 'query'」 +——对业务用户是天书,也没有下一步。原始异常不丢:调用方把 raw(e) 放进响应或 +事件的 detail 字段、写进日志,排查时照样找得到。 + +这里曾经是两份:接口层一份(测连接这类回「原因 + 怎么办」),执行层一份(节点 +报错首行只要原因)。两份各认一套异常类名、各写一套说法,同一个 401 在设置页和 +运行记录里是两句话。现在认异常的规则只有下面这一张表,两个入口只在一件事上分叉: +TypeError、KeyError 这类「程序自己的毛病」—— + +- 接口里冒出来的,是后端的 bug,原文里全是内部符号,说「后端内部出错了」; +- 节点里冒出来的,多半是工具被传错了参数、用户代码取错了字段,说清是哪个参数、 + 哪个字段,模型和人才知道该改什么。 + +判断按类名走 MRO,不 import 各家 SDK:没装的驱动和 SDK 不该让报错这一层也跟着 +ImportError。 +""" +from __future__ import annotations + +import json +import re +from typing import Any + +_TIMEOUT = {"TimeoutError", "TimeoutException", "APITimeoutError", "ReadTimeout", + "ConnectTimeout", "PoolTimeout", "WriteTimeout"} +_NETWORK = {"ConnectError", "ConnectionError", "APIConnectionError", "gaierror", + "ClientConnectorError", "RemoteProtocolError", "ReadError", "NetworkError"} +_AUTH = {"AuthenticationError", "PermissionDeniedError", "InvalidPasswordError", + "InvalidAuthorizationSpecificationError"} +#: 这几类是程序自己的毛病,str(e) 里全是内部符号,原样端上去只会更糊涂 +_BUG = {"TypeError", "AttributeError", "KeyError", "NameError", "IndexError", + "UnboundLocalError", "AssertionError", "RecursionError"} +_UNREACHABLE = ("connection refused", "nodename nor servname", "name or service not known", + "all connection attempts failed", "network is unreachable") + +#: SQLAlchemy 包过的驱动异常开头是「(sqlite3.OperationalError) 」 +_WRAPPED = re.compile(r"^\([\w.]+\)\s*") +# 内部符号:`a.b.._run()`、``、模块路径。对使用者没有意义 +_LOCALS = re.compile(r"[\w.]*\.?[\w.]*(\(\))?") +_REPR = re.compile(r"<(?:function|bound method|coroutine|object) [^>]*>") +# 包在消息里的异常类名("连不上:ConnectError: All connection attempts failed") +_CLASS = re.compile(r"\b[A-Z]\w*(?:Error|Exception|Warning)\b:\s*") + + +def raw(e: BaseException) -> str: + """原始报错,给「技术细节」折叠区、事件的 detail 字段和日志用。""" + text = str(e).strip() + return (f"{type(e).__name__}: {text}" if text else type(e).__name__)[:2000] + + +def raw_detail(e: BaseException | None) -> str | None: + """同 raw,没有异常时是 None——事件里 detail 字段就不带。""" + return None if e is None else raw(e) + + +def first_line(e: BaseException) -> str: + """异常自己的说明,去掉驱动包装的类名前缀和 SQLAlchemy 的文档链接。""" + text = str(e).strip() + line = text.splitlines()[0] if text else "" + return _WRAPPED.sub("", line)[:300] + + +def _names(e: BaseException) -> set[str]: + return {c.__name__ for c in type(e).__mro__} + + +def _status(e: BaseException) -> int | None: + for obj in (e, getattr(e, "response", None)): + code = getattr(obj, "status_code", None) + if isinstance(code, int): + return code + return None + + +def _scrub(text: str) -> str: + text = _LOCALS.sub("内部函数", text) + text = _REPR.sub("内部对象", text) + text = _CLASS.sub("", text) + return " ".join(text.split())[:240] + + +def _known(e: BaseException, *, sniff: bool) -> tuple[str, str] | None: + """两种场景说法一样的那些:鉴权、找不到、限流、对方故障、超时、网络、格式、文件。 + + sniff:类名认不出时要不要按原文字面猜。SDK 抛出来的裸异常要猜(原文就是 + "Connection refused");执行层的异常常常是已经拼好的一句话、里头套着具体原因, + 按字面猜会把那个原因换成笼统的一句。 + """ + names = _names(e) + low = str(e).lower() if sniff else "" + status = _status(e) + + if status in (401, 403) or names & _AUTH or "invalid api key" in low or "unauthorized" in low: + code = f"({status})" if status else "" + return (f"鉴权没通过{code}:对方拒绝了这把密钥", + "核对 API Key 有没有填错、过期,或者这个账号有没有这个模型的权限") + if status == 404 or "NotFoundError" in names: + return "对方说找不到(404)", "核对地址的路径(常见的是少了或多了 /v1)和模型名" + if status == 429 or "RateLimitError" in names: + return "请求太频繁,或者额度用完了(429)", "等一会儿再试;频繁出现就检查账号额度" + if isinstance(status, int) and status >= 500: + return f"对方服务出错了(HTTP {status})", "这是对方的问题,多半是暂时的,稍后再试" + if isinstance(e, TimeoutError) or names & _TIMEOUT or "timed out" in low: + return ("等待超时:对方没有在限定时间内响应", + "对方可能很忙或者网络不稳,稍后再试;一直超时就检查地址和网络") + if isinstance(e, ConnectionError) or names & _NETWORK or any(s in low for s in _UNREACHABLE): + return ("连不上对方的服务:网络不通,或者服务没有启动", + "核对地址和端口,确认服务已经启动、这台机器访问得到它") + if isinstance(e, json.JSONDecodeError): + return "返回的内容不是合法的 JSON", "地址多半指到了网页而不是 API,核对 Base URL" + if "ValidationError" in names and callable(getattr(e, "errors", None)): + return f"参数不对:{_first_validation(e)}", "" + if isinstance(e, PermissionError): + return "没有权限访问需要的文件或资源", "" + if isinstance(e, FileNotFoundError): + return f"找不到文件{'「' + str(e.filename) + '」' if e.filename else ''}", "" + if isinstance(e, (OverflowError, MemoryError)): + text = str(e).strip() + return f"数据超出了能处理的范围{':' + _scrub(text) if text else ''}", "" + return None + + +def explain(e: BaseException) -> tuple[str, str]: + """接口层:(原因, 怎么办)。原因是一句人话;说不出怎么办时第二项是空串。""" + known = _known(e, sniff=True) + if known: + return known + if _names(e) & _BUG: + return "后端内部出错了", "这不是你操作的问题;把技术细节发给维护者" + return first_line(e) or "出了一个没有说明的错误", "" + + +def describe_exception(e: BaseException) -> str: + """执行层:原因,一句话。「发生了什么」和「怎么办」由节点按场景拼。""" + text = str(e).strip() + if isinstance(e, TypeError): + if m := re.search(r"unexpected keyword argument '(\w+)'", text): + return f"参数对不上:它不接受名为「{m.group(1)}」的参数" + if m := re.search(r"missing \d+ required (?:positional |keyword-only )?arguments?: (.+)$", text): + wanted = "、".join(re.findall(r"'(\w+)'", m.group(1))) or m.group(1) + return f"缺少必填参数「{wanted}」" + if isinstance(e, KeyError): + return f"缺少字段「{e.args[0] if e.args else '?'}」" + known = _known(e, sniff=False) + if known: + return known[0] + return _scrub(text) or "没有给出原因的内部错误" + + +def message(e: BaseException, lead: str = "") -> str: + """一整句:lead + 原因 + 怎么办。给只能放一个字符串的地方(HTTPException 的 detail)。""" + reason, hint = explain(e) + text = f"{lead}{reason}" if lead else reason + return f"{text}。{hint}" if hint else text + + +def payload(e: BaseException, lead: str = "") -> dict[str, Any]: + """{ok: false, error, hint, detail}:测试连接这类「失败也是正常结果」的接口用。""" + reason, hint = explain(e) + return {"ok": False, "error": f"{lead}{reason}", "hint": hint, "detail": raw(e)} + + +def not_configured(e: BaseException) -> str: + """模型接入没配好时的原话用的是内部叫法(provider 'x'、base_url),换成设置页上的说法。""" + text = re.sub(r"provider '([^']*)'", r"模型接入「\1」", str(e)) + text = re.sub(r"模型 '([^']*)'", r"模型「\1」", text) + return text.replace("base_url", "Base URL").rstrip("。") + + +def _has_cjk(text: str) -> bool: + return any("一" <= ch <= "鿿" for ch in text) + + +def _first_validation(e: Any) -> str: + err = (e.errors() or [{}])[0] + loc = ".".join(str(p) for p in err.get("loc") or ()) + msg = str(err.get("msg") or "").removeprefix("Value error, ") + if _has_cjk(msg): + return msg + return f"「{loc}」{'缺了' if err.get('type') == 'missing' else '格式不对'}" if loc else "格式不对" + + +def graph_error(e: BaseException) -> str: + """工作流结构读不懂时,指到是哪个节点、哪一项。 + + pydantic 的原文是一大段英文,还列了全部十几种节点类型和一个文档链接, + 用户从里面找不到自己错在哪。 + """ + errors = e.errors() if callable(getattr(e, "errors", None)) else None + if not errors: + return explain(e)[0] + err = errors[0] + loc = list(err.get("loc") or ()) + msg = str(err.get("msg") or "").removeprefix("Value error, ") + more = f"(另有 {len(errors) - 1} 处)" if len(errors) > 1 else "" + + if len(loc) >= 2 and loc[0] in ("nodes", "edges") and isinstance(loc[1], int): + where = f"第 {loc[1] + 1} 个{'节点' if loc[0] == 'nodes' else '连线'}" + field = str(loc[2]) if len(loc) > 2 else "" + if err.get("type") == "enum" and field == "type": + return f"{where}的类型「{err.get('input')}」不认识{more}" + if err.get("type") == "missing": + return f"{where}缺了「{field}」{more}" + if _has_cjk(msg): + return f"{where}:{msg}{more}" + return f"{where}的「{field or '内容'}」格式不对{more}" + if _has_cjk(msg): + return msg + more + return f"「{'.'.join(str(p) for p in loc) or '整体'}」格式不对{more}" diff --git a/backend/app/data/engine.py b/backend/app/data/engine.py index ad004084..cb986582 100644 --- a/backend/app/data/engine.py +++ b/backend/app/data/engine.py @@ -7,15 +7,21 @@ from __future__ import annotations import asyncio import json +import logging +import time +from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import Any +from typing import Any, AsyncIterator, Awaitable, Callable -from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine from sqlalchemy import text from app.core.crypto import decrypt +from app.data import guard from app.data.guard import QueryLimits, SqlRejected, check, is_write +logger = logging.getLogger(__name__) + # 各方言的 SQLAlchemy 驱动。都选 asyncio 原生驱动,没有一个需要装数据库客户端: # oracledb 走 thin 模式(纯 Python 协议实现),省掉了 Oracle Instant Client 这个 # 历来最劝退的一步。 @@ -36,9 +42,16 @@ _DEFAULT_PORTS = { SUPPORTED_KINDS = tuple(sorted(set(_DRIVERS))) +#: 数据源 options 里按源配置的查询时限(秒)。表单上叫「查询时限」 +QUERY_TIMEOUT_OPTION = "query_timeout_s" +#: 按源配置时能填的范围。再长的查询不该让一个 agent 干等:该做成离线任务了 +MIN_QUERY_TIMEOUT_S = 1 +MAX_QUERY_TIMEOUT_S = 600 + # options 里这些 key 是 AgentLab 自己的配置,不是驱动参数,拼 URL 时要摘掉。 # schema:探查哪个 schema(企业库里只读账号名下常常什么都没有,数据在别处) -_NON_DRIVER_OPTIONS = frozenset({"schema"}) +# query_timeout_s:查询时限,由数据层按语句下发给数据库,见 _server_deadline +_NON_DRIVER_OPTIONS = frozenset({"schema", QUERY_TIMEOUT_OPTION}) @dataclass @@ -137,6 +150,43 @@ def engine_args(source: Any) -> tuple[str, dict[str, Any]]: return url, {} +def _timeout_value(value: Any) -> float | None: + """options 里的查询时限:表单存的是字符串,也认数字。填得不对返回 None。""" + if value is None or isinstance(value, bool): + return None + try: + seconds = float(str(value).strip()) + except ValueError: + return None + if not MIN_QUERY_TIMEOUT_S <= seconds <= MAX_QUERY_TIMEOUT_S: + return None + return int(seconds) if seconds.is_integer() else seconds + + +def query_timeout_problem(options: dict[str, Any] | None) -> str | None: + """保存数据源时查一下查询时限填得对不对;没填、或者填对了返回 None。""" + value = (options or {}).get(QUERY_TIMEOUT_OPTION) + if value is None or str(value).strip() == "": + return None + if _timeout_value(value) is None: + return (f"查询时限要填 {MIN_QUERY_TIMEOUT_S} 到 {MAX_QUERY_TIMEOUT_S} 之间的秒数," + f"比如 60;现在填的是「{value}」") + return None + + +def query_timeout(source: Any) -> float: + """这个数据源上一条查询最多跑多久(秒)。没配、或者库里存着的值不对,用缺省。 + + 缺省按模块属性现取:只有 guard.QueryLimits 这一处定义。 + """ + value = _timeout_value((source.options or {}).get(QUERY_TIMEOUT_OPTION)) + return value if value is not None else guard.QueryLimits().timeout_seconds + + +def _seconds(value: float) -> str: + return str(int(value)) if float(value).is_integer() else f"{value:g}" + + class EngineCache: """按数据源 id 缓存 engine。连接池的建立不便宜,不该每次查询都重来一遍。 @@ -184,8 +234,6 @@ engines = EngineCache() async def test_connection(source: Any) -> dict[str, Any]: """测连接。失败时把驱动的原始错误带出来——这类问题九成靠错误信息定位。""" - import time - started = time.perf_counter() try: engine = await engines.get(source) @@ -206,6 +254,199 @@ async def test_connection(source: Any) -> dict[str, Any]: } +#: 数据库按时限停下语句之后,数据层自己的兜底再多等多久。要比引擎的宽限 +#: (toolcalls.GRACE_S)短:数据库报上来的「超过 Ns 被中断」才是给人看的那句, +#: 引擎那边只在数据层也卡住时才出面 +BACKSTOP_S = 0.5 + +#: SQLite 每执行多少条虚拟机指令问一次进度回调。回调只比一次时间,几毫秒一问足够准 +_SQLITE_STEPS = 10_000 + +#: 各家数据库按时限停下语句时的报错。认出来就说「超时」,而不是把驱动原文当成 +#: SQL 写错了交给模型——它会去改一条本来没错的 SQL。 +#: +#: 先认错误码:服务器的报错文字会跟着它的语言设置走,中文环境的 PostgreSQL 报的是 +#: 「由于语句执行超时,正在取消查询命令」,按英文原文找一个也认不出来 +_PG_CANCELED = "57014" # PostgreSQL query_canceled(statement_timeout 到点) +_MYSQL_TIMEOUT_CODES = frozenset({3024, # MySQL:max_execution_time 到点 + 1969}) # MariaDB:max_statement_time 到点 +_TIMEOUT_MARKERS = ( + "statement timeout", # PostgreSQL(英文环境) + "maximum statement execution time", # MySQL 3024 + "max_statement_time", # MariaDB 1969 + "dpy-4024", "dpi-1067", "call timeout", # python-oracledb:call timeout of N ms exceeded(驱动自己的话,不随服务器语言变) +) + + +def _causes(e: BaseException) -> list[BaseException]: + """异常连同它包着的原始驱动异常。SQLAlchemy 包一层(.orig),适配器再包一层(__cause__), + 有的路径(服务端游标取行)又原样抛出驱动异常,所以几条链都走一遍。""" + out: list[BaseException] = [] + todo: list[Any] = [e] + while todo and len(out) < 8: + err = todo.pop(0) + if isinstance(err, BaseException) and err not in out: + out.append(err) + todo += [getattr(err, "orig", None), err.__cause__, err.__context__] + return out + + +class _Deadline: + """一条语句的时限。数据库那边按它停下;停下时报的错据此认成超时。""" + + def __init__(self, seconds: float) -> None: + self.seconds = seconds + self.ends = time.monotonic() + seconds + #: SQLite 的进度回调真的掐过 + self.fired = False + #: 语句已经结束。回调这时要是还挂在连接上(摘除没成功),也不能再掐别人的语句 + self.over = False + + @property + def ms(self) -> int: + return max(1, int(self.seconds * 1000)) + + def expired(self) -> bool: + return time.monotonic() >= self.ends + + def sqlite_progress(self) -> int: + # 在 SQLite 执行语句的那个线程里被调用;返回非零,SQLite 就中止当前语句 + if not self.over and time.monotonic() >= self.ends: + self.fired = True + return 1 + return 0 + + def stopped(self, e: BaseException) -> bool: + """这个报错是不是数据库按时限停下语句造成的。""" + if self.fired: + return True + for err in _causes(e): + if _PG_CANCELED in (getattr(err, "sqlstate", None), getattr(err, "pgcode", None)): + return True + args = getattr(err, "args", None) or () + if args and isinstance(args[0], int) and args[0] in _MYSQL_TIMEOUT_CODES: + return True + low = str(err).lower() + if any(marker in low for marker in _TIMEOUT_MARKERS): + return True + # 各家的措辞不一(interrupted / canceled,或者干脆只在类名里); + # 过了时限才报出来的,就是时限掐的 + if self.expired() and any(w in f"{type(err).__name__} {low}".lower() + for w in ("interrupt", "cancel")): + return True + return False + + +async def _driver(conn: AsyncConnection) -> Any: + return (await conn.get_raw_connection()).driver_connection + + +@asynccontextmanager +async def _server_deadline(conn: AsyncConnection, kind: str, deadline: _Deadline) -> AsyncIterator[None]: + """让数据库自己在时限处停下这条语句。 + + 只靠后端这边 wait_for 放弃等待是不够的:数据库那边的语句照样在跑,占着连接池 + 里的一个连接直到跑完(Oracle 实测多占了 60 秒);aiosqlite 更糟,语句在线程里 + 跑,取消之后关连接要排在它后面,一条不收敛的查询能让调用永远回不来。 + + 会话级的设置用完要恢复:连接会回到池子里,探查结构这类长操作也用它。 + 下发失败(老版本的 MySQL 没有这个变量)不挡查询,只剩后端这边的兜底。 + """ + if kind in ("postgres", "postgresql"): + # SET LOCAL 只管这一个事务:连接还回池子时回滚,设置随之作废 + await conn.execute(text(f"SET LOCAL statement_timeout = {deadline.ms}")) + yield + return + + if kind in ("mysql", "mariadb"): + # MySQL 的 max_execution_time 以毫秒计、只管 SELECT;MariaDB 没有它, + # 用 max_statement_time(秒)。按连上的服务器认,不按表单里选的类型 + if getattr(conn.dialect, "is_mariadb", False): + var, value = "max_statement_time", _seconds(deadline.seconds) + else: + var, value = "max_execution_time", str(deadline.ms) + try: + await conn.execute(text(f"SET SESSION {var} = {value}")) + except Exception as e: # noqa: BLE001 + logger.warning("MySQL 语句时限没下发成功(%s),只剩后端兜底", e) + yield + return + cancelled = False + try: + yield + except asyncio.CancelledError: + # 连接还卡在那条语句上,这时再发一条只会排在它后面 + cancelled = True + raise + finally: + if not cancelled: + try: + await conn.execute(text(f"SET SESSION {var} = DEFAULT")) + except Exception: # noqa: BLE001 - 连接已经坏了的话,连接池会把它作废 + pass + return + + if kind == "oracle": + driver = await _driver(conn) + before = getattr(driver, "call_timeout", 0) + try: + driver.call_timeout = deadline.ms + except Exception as e: # noqa: BLE001 + logger.warning("Oracle call_timeout 没设上(%s),只剩后端兜底", e) + yield + return + try: + yield + finally: + try: + driver.call_timeout = before + except Exception: # noqa: BLE001 - 超时后连接可能已经被驱动关掉 + pass + return + + if kind == "sqlite": + driver = await _driver(conn) + await driver.set_progress_handler(deadline.sqlite_progress, _SQLITE_STEPS) + try: + yield + finally: + deadline.over = True + try: + await driver.set_progress_handler(None, 0) + except Exception: # noqa: BLE001 - over 已经让它不再掐人 + pass + return + + yield + + +async def _bounded( + opener: Callable[[], Any], kind: str, limits: QueryLimits, over: str, + work: Callable[[AsyncConnection], Awaitable[Any]], +) -> Any: + """在 opener() 开的连接上跑 work,时限交给数据库执行,后端这边再留一道兜底。 + + 数据库按时限停下语句时报的错,翻成 over 这句话:「超过 Ns 被中断」。 + """ + deadline: _Deadline | None = None + + async def _exec() -> Any: + nonlocal deadline + async with opener() as conn: + deadline = _Deadline(limits.timeout_seconds) + async with _server_deadline(conn, kind, deadline): + return await work(conn) + + try: + return await asyncio.wait_for(_exec(), timeout=limits.timeout_seconds + BACKSTOP_S) + except asyncio.TimeoutError as e: + raise SqlRejected(over) from e + except Exception as e: + if deadline is not None and deadline.stopped(e): + raise SqlRejected(over) from e + raise + + async def run_query( source: Any, sql: str, *, limits: QueryLimits | None = None ) -> QueryResult: @@ -213,44 +454,40 @@ async def run_query( 注意执行的是 guard.check 的返回值而不是原始输入——守卫会把尾分号之类 规范掉,执行原文等于绕过了守卫。 - """ - import time - limits = limits or QueryLimits() + 时限没指定时按数据源自己的配置(query_timeout),由数据库执行(_server_deadline)。 + """ + limits = limits or QueryLimits(timeout_seconds=query_timeout(source)) statement = check(sql, readonly=bool(source.readonly), source_name=source.name) + kind = (source.kind or "").lower() engine = await engines.get(source) started = time.perf_counter() if not source.readonly and is_write(statement): - return await _run_write(engine, statement, limits, started) + return await _run_write(engine, kind, statement, limits, started) - async def _exec() -> tuple[list[str], list[list[Any]], bool]: - async with engine.connect() as conn: - cursor = await conn.stream(text(statement)) - columns = list(cursor.keys()) - rows: list[list[Any]] = [] - truncated = False - size = 0 - async for row in cursor: - # 逐行累加,边收边判上限:一次性 fetchall 一个亿级表就晚了 - values = [_jsonable(v) for v in row] - rows.append(values) - size += len(json.dumps(values, ensure_ascii=False, default=str)) - if len(rows) >= limits.max_rows or size >= limits.max_bytes: - truncated = True - break - return columns, rows, truncated - - try: - columns, rows, truncated = await asyncio.wait_for( - _exec(), timeout=limits.timeout_seconds - ) - except asyncio.TimeoutError as e: - raise SqlRejected( - f"查询超过 {limits.timeout_seconds}s 被中断。加上 WHERE 条件或 LIMIT 缩小范围。" - ) from e + async def _read(conn: AsyncConnection) -> tuple[list[str], list[list[Any]], bool]: + cursor = await conn.stream(text(statement)) + columns = list(cursor.keys()) + rows: list[list[Any]] = [] + truncated = False + size = 0 + async for row in cursor: + # 逐行累加,边收边判上限:一次性 fetchall 一个亿级表就晚了 + values = [_jsonable(v) for v in row] + rows.append(values) + size += len(json.dumps(values, ensure_ascii=False, default=str)) + if len(rows) >= limits.max_rows or size >= limits.max_bytes: + truncated = True + break + return columns, rows, truncated + columns, rows, truncated = await _bounded( + engine.connect, kind, limits, + f"查询超过 {_seconds(limits.timeout_seconds)}s 被中断。加上 WHERE 条件或 LIMIT 缩小范围。", + _read, + ) return QueryResult( columns=columns, rows=rows, @@ -262,7 +499,7 @@ async def run_query( async def _run_write( - engine: AsyncEngine, statement: str, limits: QueryLimits, started: float + engine: AsyncEngine, kind: str, statement: str, limits: QueryLimits, started: float ) -> QueryResult: """可写源上的写操作:真的提交。 @@ -271,20 +508,19 @@ async def _run_write( 能走到这里的写都已经过了审批(registry.call_is_dangerous → 各节点的审批关卡), 只读源上的写在 check 里就被拒了。 """ - import time - async def _exec() -> tuple[list[str], list[list[Any]]]: - async with engine.begin() as conn: # 正常退出提交,出错回滚 - result = await conn.execute(text(statement)) - if result.returns_rows: # RETURNING、PG 的数据修改 CTE - return (list(result.keys()), - [[_jsonable(v) for v in row] for row in result.fetchmany(limits.max_rows)]) - return ["affected_rows"], [[result.rowcount]] + async def _write(conn: AsyncConnection) -> tuple[list[str], list[list[Any]]]: + result = await conn.execute(text(statement)) + if result.returns_rows: # RETURNING、PG 的数据修改 CTE + return (list(result.keys()), + [[_jsonable(v) for v in row] for row in result.fetchmany(limits.max_rows)]) + return ["affected_rows"], [[result.rowcount]] - try: - columns, rows = await asyncio.wait_for(_exec(), timeout=limits.timeout_seconds) - except asyncio.TimeoutError as e: - raise SqlRejected(f"写操作超过 {limits.timeout_seconds}s 被中断,已回滚。") from e + # engine.begin():正常退出提交,出错(包括数据库按时限停下)回滚 + columns, rows = await _bounded( + engine.begin, kind, limits, + f"写操作超过 {_seconds(limits.timeout_seconds)}s 被中断,已回滚。", _write, + ) return QueryResult( columns=columns, rows=rows, diff --git a/backend/app/data/guard.py b/backend/app/data/guard.py index eda0e85c..f49f6616 100644 --- a/backend/app/data/guard.py +++ b/backend/app/data/guard.py @@ -83,7 +83,8 @@ class SqlRejected(ValueError): class QueryLimits: max_rows: int = 1000 max_bytes: int = 1_000_000 - timeout_seconds: int = 30 + #: 数据源可以在 options.query_timeout_s 里自己定(见 data.engine.query_timeout),可以带小数 + timeout_seconds: float = 30 def strip_comments(sql: str) -> str: diff --git a/backend/app/data/introspect.py b/backend/app/data/introspect.py index 0d55d80c..8d1a9fbd 100644 --- a/backend/app/data/introspect.py +++ b/backend/app/data/introspect.py @@ -279,17 +279,43 @@ def _detailed_summary(source: Any, *, max_tables: int = 40) -> str: return "\n".join(lines) -def describe_table(source: Any, table: str) -> str: - """单表的完整字段说明。agent 调 db_schema 工具时返回这个。""" - cache = source.schema_cache or {} - tables = cache.get("tables") or {} - # 模型可能传全名(ANALYTICS.V_TRIP_FACT),也可能只传表名,两种都认 +def find_table(source: Any, table: str) -> dict[str, Any] | None: + """在缓存里找一张表。全名、只有表名、大小写不一致都认——模型和人都可能这么写。""" + tables = (source.schema_cache or {}).get("tables") or {} meta = tables.get(table) if meta is None and "." in table: meta = tables.get(table.rsplit(".", 1)[-1]) if meta is None: lowered = table.rsplit(".", 1)[-1].lower() meta = next((m for n, m in tables.items() if n.lower() == lowered), None) + return meta or None + + +def table_columns(meta: dict[str, Any]) -> list[dict[str, Any]]: + """结构化的列:{name, type, pk, not_null, comment}。 + + 界面要画表格,以前只能去解析 describe_table 那段给模型看的文本,文本格式 + 一改就全乱。两份出自同一份缓存,说法一致。 + """ + pk = set(meta.get("primary_key") or []) + return [ + { + "name": col["name"], + "type": col.get("type") or "", + "pk": col["name"] in pk, + "not_null": not col.get("nullable", True), + "comment": col.get("comment") or None, + } + for col in meta.get("columns") or [] + ] + + +def describe_table(source: Any, table: str) -> str: + """单表的完整字段说明。agent 调 db_schema 工具时返回这个。""" + cache = source.schema_cache or {} + tables = cache.get("tables") or {} + # 模型可能传全名(ANALYTICS.V_TRIP_FACT),也可能只传表名,两种都认 + meta = find_table(source, table) if not meta: if not tables: # 缓存是空的,说"里面没有这张表"就是在误导——真相是我们什么都不知道。 diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 3d4c1eae..44496088 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -161,6 +161,9 @@ _COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [ # 存量轮次没有复核结论。NULL 正好表示"这一轮没复核过",和"复核过、没问题" # (存 verdict=ok)是两回事,前端要分得开 ("conversation_turns", "review", "JSON"), + ("conversation_turns", "meta", "JSON"), + ("data_sources", "last_check", "JSON"), + ("mcp_servers", "last_check", "JSON"), ] diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 30b3d0e4..be491db7 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -59,6 +59,8 @@ class DataSource(Base, TimestampMixin): schema_cache: Mapped[dict[str, Any]] = mapped_column(default=dict) schema_synced_at: Mapped[datetime | None] = mapped_column(default=None) enabled: Mapped[bool] = mapped_column(default=True) + # 最近一次测连接:{at, ok, latency_ms, error}。记在对象上,换台浏览器也看得到 + last_check: Mapped[dict[str, Any] | None] = mapped_column(default=None) class Setting(Base, TimestampMixin): @@ -85,6 +87,8 @@ class McpServer(Base, TimestampMixin): status: Mapped[str] = mapped_column(String(20), default="unknown") last_error: Mapped[str | None] = mapped_column(Text, default=None) tools_cache: Mapped[list[Any]] = mapped_column(default=list) + # 最近一次连接的时刻和耗时(status / last_error 只有结论,没有「何时、多快」) + last_check: Mapped[dict[str, Any] | None] = mapped_column(default=None) class CustomTool(Base, TimestampMixin): @@ -435,5 +439,8 @@ class ConversationTurn(Base, TimestampMixin): # 命中的信号清单。落库是因为它是答案的一部分——刷新页面后只剩一个 # 看起来很完整的答案、而"它哪里不可靠"没了,比不复核更糟 review: Mapped[dict[str, Any] | None] = mapped_column(default=None) + # 这一轮的可信度元数据(出具档位、运行类别、未查库、结局、耗时、查库次数…), + # 前端写、前端读。单独一列:塞在 review 里的话,没复核过的轮次 review 也不为空 + meta: Mapped[dict[str, Any] | None] = mapped_column(default=None) conversation: Mapped[Conversation] = relationship(back_populates="turns") diff --git a/backend/app/engine/compiler.py b/backend/app/engine/compiler.py index acdb11b1..e7de975f 100644 --- a/backend/app/engine/compiler.py +++ b/backend/app/engine/compiler.py @@ -8,9 +8,9 @@ from typing import Any, Awaitable, Callable from langgraph.errors import GraphBubbleUp from langgraph.graph import END, START, StateGraph +from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.engine.context import NodeContext, NodeError, RunContext -from app.engine.errors import describe_exception, raw_detail from app.engine.nodes import control, human, io, knowledge, llm, metrics, multi, tools from app.engine.schema import GraphNode, GraphSpec, NodeType, back_edges, innermost_loops from app.engine.state import GraphState diff --git a/backend/app/engine/errors.py b/backend/app/engine/errors.py deleted file mode 100644 index 57d71f0d..00000000 --- a/backend/app/engine/errors.py +++ /dev/null @@ -1,81 +0,0 @@ -"""把异常翻成给人看的一句话。 - -界面上出现过的原样报错:「工具 db_query__shop 执行失败:_make_query_tool.._run() -got an unexpected keyword argument 'query'」「TypeError: Integer exceeds 64-bit range」。 -问数据的业务用户读不懂这些,也不知道下一步该做什么。 - -这里只管"原因"那半句,不带异常类名和内部符号;"发生了什么"和"怎么办"由调用方 -按场景拼。原始异常另外放进事件的 detail 字段、写进日志,排查的人照样拿得到。 -""" -from __future__ import annotations - -import json -import re - -# 供应商 SDK / httpx 的网络类异常。按类名认而不 import:这些包不一定都装了, -# 而这里只需要知道"是不是连不上" -_NETWORK = {"ConnectError", "ConnectTimeout", "APIConnectionError", "RemoteProtocolError", - "ReadError", "NetworkError"} -_TIMEOUT = {"ReadTimeout", "WriteTimeout", "PoolTimeout", "APITimeoutError", "TimeoutException"} - -# 内部符号:`a.b.._run()`、``、模块路径。对使用者没有意义 -_LOCALS = re.compile(r"[\w.]*\.?[\w.]*(\(\))?") -_REPR = re.compile(r"<(?:function|bound method|coroutine|object) [^>]*>") -# 包在消息里的异常类名("连不上:ConnectError: All connection attempts failed") -_CLASS = re.compile(r"\b[A-Z]\w*(?:Error|Exception|Warning)\b:\s*") - - -def _names(exc: BaseException) -> set[str]: - return {cls.__name__ for cls in type(exc).__mro__} - - -def _scrub(text: str) -> str: - text = _LOCALS.sub("内部函数", text) - text = _REPR.sub("内部对象", text) - text = _CLASS.sub("", text) - return " ".join(text.split())[:240] - - -def describe_exception(exc: BaseException) -> str: - """原因,一句话。常见的几类写成固定说法,其余的去掉内部符号后照实说。""" - names = _names(exc) - text = str(exc).strip() - - status = getattr(exc, "status_code", None) - if isinstance(status, int): - if status in (401, 403): - return f"鉴权没通过({status}):key 无效、过期,或没有这个模型的权限" - if status == 429: - return "请求太频繁,或者额度用完了(429)" - if status >= 500: - return f"对方服务出错了({status}),多半是暂时的" - - if isinstance(exc, TypeError): - if m := re.search(r"unexpected keyword argument '(\w+)'", text): - return f"参数对不上:它不接受名为「{m.group(1)}」的参数" - if m := re.search(r"missing \d+ required (?:positional |keyword-only )?arguments?: (.+)$", text): - wanted = "、".join(re.findall(r"'(\w+)'", m.group(1))) or m.group(1) - return f"缺少必填参数「{wanted}」" - if isinstance(exc, TimeoutError) or names & _TIMEOUT: - return "等待超时:对方没有在限定时间内响应" - if isinstance(exc, ConnectionError) or names & _NETWORK: - return "连不上对方服务:网络不通,或者服务没有启动" - if isinstance(exc, PermissionError): - return "没有权限访问需要的文件或资源" - if isinstance(exc, FileNotFoundError): - return f"找不到文件{'「' + str(exc.filename) + '」' if exc.filename else ''}" - if isinstance(exc, json.JSONDecodeError): - return "返回的内容不是合法的 JSON" - if isinstance(exc, KeyError): - return f"缺少字段「{exc.args[0] if exc.args else '?'}」" - if isinstance(exc, (OverflowError, MemoryError)): - return f"数据超出了能处理的范围{':' + _scrub(text) if text else ''}" - return _scrub(text) or "没有给出原因的内部错误" - - -def raw_detail(exc: BaseException | None) -> str | None: - """原始异常,给排查的人看。只进事件的 detail 字段和日志,不进首行报错。""" - if exc is None: - return None - text = str(exc).strip() - return f"{type(exc).__name__}: {text}"[:2000] if text else type(exc).__name__ diff --git a/backend/app/engine/nodes/human.py b/backend/app/engine/nodes/human.py index 0e9e0841..c628c8c4 100644 --- a/backend/app/engine/nodes/human.py +++ b/backend/app/engine/nodes/human.py @@ -1,15 +1,17 @@ from __future__ import annotations import json +import re +import time from typing import Any -from langgraph.types import interrupt - from app.core.events import EventType from app.db.base import SessionLocal from app.engine.approval import read_decision from app.engine.context import NodeContext, NodeError -from app.engine.state import GraphState, message_text, template_context +from app.engine.replay import ask, once +from app.engine.state import GraphState, merge_usage, message_text, template_context +from app.providers import catalog from app.providers.factory import ModelSpec, get_chat_model @@ -32,10 +34,9 @@ async def run_human(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if mode == "edit": payload["draft"] = ctx.render(ctx.cfg("draft", ""), state) - ctx.emit(EventType.HUMAN_REQUESTED, **payload) - response = interrupt(payload) + response = await ask(ctx, payload, **payload) # 谁批的跟着事件走:时间线要写得出"张工驳回了",没署名就是 None,不再默认写"你" - ctx.emit(EventType.HUMAN_RESOLVED, response=response, actor=ctx.actor()) + await once(ctx, EventType.HUMAN_RESOLVED, response=response, actor=ctx.actor()) decision = read_decision(response) if mode == "approve": @@ -79,6 +80,7 @@ async def run_validate(state: GraphState, ctx: NodeContext) -> dict[str, Any]: """按 JSON Schema 校验上游产出。 校验失败时可以把错误原样喂回模型让它改 —— 这比在 prompt 里祈祷它输出正确格式可靠得多。 + 但修复只许改格式:修出来的值要能在原文里找到,找不到的就是模型编的(见 _invented)。 """ import jsonschema @@ -87,24 +89,35 @@ async def run_validate(state: GraphState, ctx: NodeContext) -> dict[str, Any]: raise NodeError(ctx.node.id, "校验节点需要一个 JSON Schema") source_cfg = ctx.cfg("source", "{{ last_message }}") - raw = ctx.render(source_cfg, state) if not isinstance(source_cfg, str) else ctx.render_str(source_cfg, state) + original = ctx.render(source_cfg, state) if not isinstance(source_cfg, str) else ctx.render_str(source_cfg, state) max_retries = int(ctx.cfg("max_retries", 2) or 0) repair = bool(ctx.cfg("repair_with_llm", True)) attempt = 0 errors: list[str] = [] + raw: Any = original value: Any = raw + usage: dict[str, Any] = {} while True: value, parse_error = _coerce_json(raw) + invented = [] if parse_error or raw is original else _invented(value, original, schema) if parse_error: errors = [parse_error] + elif invented: + # 修复编出了原文没有的值(真实运行里是凭空的 total_count: 0)。这次修复作废, + # 下一次仍从原文修,而不是在编出来的那份上接着改 + errors = [f"修复时出现了原文没有的值:{'、'.join(invented[:5])}"] + ctx.emit(EventType.LOG, level="warn", code="repair_invented", + message=f"第 {attempt} 次修复作废:{errors[0]}") else: try: jsonschema.validate(value, schema) ctx.emit(EventType.LOG, level="info", message=f"校验通过(第 {attempt + 1} 次尝试)") result = {"valid": True, "data": value, "attempts": attempt + 1, "errors": []} updates: dict[str, Any] = {"nodes": {ctx.node.id: result}} + if usage: + updates["usage"] = usage var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: value} @@ -113,17 +126,19 @@ async def run_validate(state: GraphState, ctx: NodeContext) -> dict[str, Any]: path = "/".join(str(p) for p in e.absolute_path) or "(根)" errors = [f"{path}: {e.message}"] - ctx.emit(EventType.LOG, level="warn", message=f"第 {attempt + 1} 次校验失败:{errors[0]}", - code="validate_retry") + if not invented: + ctx.emit(EventType.LOG, level="warn", message=f"第 {attempt + 1} 次校验失败:{errors[0]}", + code="validate_retry") attempt += 1 if attempt > max_retries or not repair: break - raw = await _repair(raw, schema, errors, ctx, state) + raw, spent = await _repair(original, schema, errors, ctx) + usage = merge_usage(usage, spent) result = {"valid": False, "data": value, "attempts": attempt, "errors": errors} if ctx.cfg("fail_fast", True): raise NodeError(ctx.node.id, f"结构化校验未通过:{'; '.join(errors)}") - return {"nodes": {ctx.node.id: result}} + return {"nodes": {ctx.node.id: result}, **({"usage": usage} if usage else {})} def _coerce_json(raw: Any) -> tuple[Any, str | None]: @@ -150,18 +165,155 @@ def _coerce_json(raw: Any) -> tuple[Any, str | None]: return text, "输出不是合法 JSON" -async def _repair(raw: Any, schema: dict[str, Any], errors: list[str], ctx: NodeContext, - state: GraphState) -> str: +async def _repair(raw: Any, schema: dict[str, Any], errors: list[str], + ctx: NodeContext) -> tuple[str, dict[str, Any]]: + """让模型只改格式地重写一遍。返回 (重写的文本, 这次调用的用量)。""" async with SessionLocal() as session: - model, _ = await get_chat_model( + model, model_id = await get_chat_model( session, ModelSpec(provider=ctx.cfg("provider"), model=ctx.cfg("model"), max_tokens=4096), ) prompt = ( - "下面这段输出没有通过 JSON Schema 校验,请修正后重新输出。\n\n" + "下面这段输出没有通过 JSON Schema 校验,请只调整格式后重新输出。\n\n" + "规矩:你只能搬运原始输出里已经有的值,不许补数据。原始输出里没有的值一律填 null," + "即使 Schema 要求它不能为空——不许用 0、空字符串或任何猜测的值凑数,不许编造。" + "值要从原文里原样抄过来。\n\n" f"Schema:\n{json.dumps(schema, ensure_ascii=False, indent=2)}\n\n" f"错误:\n- " + "\n- ".join(errors) + "\n\n" - f"原始输出:\n{str(raw)[:6000]}\n\n" + f"原始输出:\n{_as_text(raw)[:6000]}\n\n" "只输出修正后的 JSON,不要任何解释或代码围栏。" ) - return message_text(await model.ainvoke(prompt)) + started = time.perf_counter() + response = await model.ainvoke(prompt) + # 修复是一次真实的模型调用,以前不发 llm.end、不进 usage,钱花了账上看不见 + meta = getattr(response, "usage_metadata", None) or {} + inp, out = int(meta.get("input_tokens") or 0), int(meta.get("output_tokens") or 0) + spent = {"input_tokens": inp, "output_tokens": out, "total_tokens": inp + out, + "cost_usd": round(catalog.estimate_cost(model_id, inp, out), 6), "calls": 1} + ctx.emit(EventType.LLM_END, model=model_id, purpose="repair", + duration_ms=int((time.perf_counter() - started) * 1000), **spent) + return message_text(response), spent + + +def _as_text(raw: Any) -> str: + return raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False, default=str) + + +# 原文里的数字:可带千分位、小数、正负号 +_NUMBER = re.compile(r"-?\d[\d,]*(?:\.\d+)?") +_PERCENT = re.compile(r"(-?\d[\d,]*(?:\.\d+)?)\s*[%%]") +# 字符串值里要能在原文找到的片段:数字、拉丁词、连续的汉字 +_PIECE = re.compile(r"\d+(?:\.\d+)?|[A-Za-z]+|[\u4e00-\u9fff]+") + +# 数字的证据不从代码里取。「假设调用工具 + 一段 SQL」正是最常见的造假形状,而 SQL 里 +# 到处是数字:WHERE status > 0、deleted = 0、LIMIT 10。认它们的话,编出来的 +# total_count: 0 就有了「出处」。要去掉的是:代码围栏(sql / python / shell 之类)、 +# "sql": "…" 这类字符串值、以及正文里看起来像 SQL 的那一段(SELECT … FROM … 到句末, +# 后面接着的 WHERE / LIMIT 之类的行一并算进去) +_CODE_FENCE = re.compile( + r"```[ \t]*(?:sql|mysql|postgres(?:ql)?|sqlite|plsql|python|py|bash|sh|shell|zsh" + r"|javascript|js|typescript|ts)\b.*?(?:```|$)", re.I | re.S) +_CODE_VALUE = re.compile(r'"(?:sql|query|statement|code|script|command)"\s*:\s*"(?:[^"\\]|\\.)*"', + re.I) +_SQL_RUN = re.compile( + r"\b(?:select\b|with\s+(?:recursive\s+)?\w+\s*(?:\([^)]*\))?\s+as\s*\()" + r"(?:(?!\.\s)[^;;。]){0,400}?\bfrom\b[^\n;;。,]*" + r"(?:\n[ \t]*(?:where|and|or|group|order|having|limit|offset|union|on|join|left|right|inner" + r"|outer|full|cross)\b[^\n;;。,]*)*", re.I) + + +def _without_code(text: str) -> str: + for pattern in (_CODE_FENCE, _CODE_VALUE, _SQL_RUN): + text = pattern.sub(" ", text) + return text + + +def _numbers_in(text: str) -> set[float]: + """原文里(代码之外)所有数字的值。中文写法(十二条、3.5万)按出具回指那套规则一并认; + 带百分号的另认它的小数写法(12.5% → 0.125 是格式换算,不是编造)。""" + from app.engine.issuance import extract_numbers + + text = _without_code(text) + out: set[float] = {token.value for token in extract_numbers(text)} + for token in _NUMBER.findall(text): + try: + out.add(float(token.replace(",", ""))) + except ValueError: + continue + for token in _PERCENT.findall(text): + try: + out.add(float(token.replace(",", "")) / 100) + except ValueError: + continue + return out + + +def _literals(schema: Any) -> set[str]: + """Schema 自己规定的取值(enum / const):把「成功」映射成 "ok" 是格式,不是编造。""" + found: set[str] = set() + if isinstance(schema, dict): + for key in ("enum", "const"): + value = schema.get(key) + for item in value if isinstance(value, list) else [value]: + if item is not None: + found.add(str(item)) + for value in schema.values(): + found |= _literals(value) + elif isinstance(schema, list): + for value in schema: + found |= _literals(value) + return found + + +def _invented(value: Any, original: Any, schema: dict[str, Any]) -> list[str]: + """修复结果里原文没有的值,写成 "路径=值"。 + + 数字按数值比(原文写 1,284,修出来 1284 算有);非空字符串要么原样出现在原文里, + 要么它的每个数字、拉丁词、汉字片段都能在原文里找到(2026年9月1日 → 2026-09-01 + 算搬运)。布尔、null 不查:null 正是「没有这个数据」的诚实写法。 + """ + text = _as_text(original) + lowered = text.lower() + numbers = _numbers_in(text) + allowed = _literals(schema) + found: list[str] = [] + + def known_number(n: float) -> bool: + return any(abs(n - m) <= 1e-9 for m in numbers) + + def walk(node: Any, path: str) -> None: + if isinstance(node, dict): + for key, item in node.items(): + walk(item, f"{path}.{key}" if path else str(key)) + elif isinstance(node, list): + for i, item in enumerate(node): + walk(item, f"{path}[{i}]") + elif isinstance(node, bool) or node is None: + return + elif isinstance(node, (int, float)): + if not known_number(float(node)): + found.append(f"{path or '(根)'}={node}") + elif isinstance(node, str): + s = node.strip() + if not s: + if '""' not in text: + found.append(f'{path or "(根)"}=""') + return + if s in allowed: + return + if _NUMBER.fullmatch(s): + # 写成字符串的数字照数字查:"0" 不能因为 SQL 里有个 > 0 就算有出处 + if not known_number(float(s.replace(",", ""))): + found.append(f"{path or '(根)'}={s[:40]}") + return + if s.lower() in lowered: + return + pieces = _PIECE.findall(s) + ok = bool(pieces) and all( + known_number(float(p)) if p[0].isdigit() else p.lower() in lowered for p in pieces + ) + if not ok: + found.append(f"{path or '(根)'}={s[:40]}") + + walk(value, "") + return found diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index fecb7b96..7789d14d 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -78,6 +78,8 @@ def _apply_contract( # 没有它,一个写错的节点名会让所有列表都是空的,看起来和"全部通过"一模一样。 gaps: list[str] = [] unresolved: list[str] = [] + #: 指标 id → 它来自哪张口径卡(「销售口径 @ v2」),逐个数字回指时标出处 + caliber_of: dict[str, str] = {} for source in sources: payload = nodes.get(source) if isinstance(payload, dict) and payload.get("kind") == "metric_set": @@ -89,6 +91,9 @@ def _apply_contract( "version": payload.get("caliber_version", ""), } ) + label = " @ ".join(str(v) for v in (payload.get("caliber"), payload.get("caliber_version")) if v) + for m in payload.get("metrics") or []: + caliber_of.setdefault(str(m.get("id")), label) else: # 节点不存在、还没跑到、或者根本不是口径卡——指标集就是缺的, # 不能当作"这次没有指标要查" @@ -121,6 +126,14 @@ def _apply_contract( if sources and not metrics: gaps.append("指标集为空,叙述里的数字无从回指") + # 上游有协作团队用完轮数、按降档交付的:叙述里的话不是调度者认可的结论, + # 数字全都对得上也不能盖「完整出具」 + titles = {n.id: n.title for n in ctx.run.spec.nodes} + for node_id, payload in nodes.items(): + if isinstance(payload, dict) and payload.get("exhausted"): + gaps.append(f"协作团队「{titles.get(node_id, node_id)}」用完 {payload.get('rounds', '?')} " + "轮仍未完成,交来的是成员最后的原话") + tier = decide_tier( missing_required=missing_required, missing_expected=missing_expected, @@ -129,6 +142,11 @@ def _apply_contract( gaps=gaps, ) + # 逐个数字的出处。以前只有一个计数,界面标不出「这个数来自哪个指标」 + matched = [ + {**hit, **({"caliber": caliber_of[hit["metric"]]} if caliber_of.get(hit.get("metric")) else {})} + for hit in (trace.matched if trace else []) + ][:50] issuance = { "tier": tier, "calibers": calibers, # 口径版本照常印 @@ -137,6 +155,7 @@ def _apply_contract( "missing_expected": missing_expected, # 缺数据声明照常印 "unmatched_numbers": unmatched[:20], "matched_numbers": len(trace.matched) if trace else 0, + "matched": matched, # 校验没跑全的原因照实印出来——读的人要能分辨"查过都对"和"根本没查" "gaps": gaps, "declared_at": datetime.now(timezone.utc).isoformat(), @@ -153,6 +172,7 @@ def _apply_contract( gaps=gaps, metrics_checked=len(metrics), matched_numbers=len(trace.matched) if trace else 0, + matched=matched, ) return issuance diff --git a/backend/app/engine/nodes/llm.py b/backend/app/engine/nodes/llm.py index 88c002b9..fe676317 100644 --- a/backend/app/engine/nodes/llm.py +++ b/backend/app/engine/nodes/llm.py @@ -7,17 +7,27 @@ from typing import Any from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage from langchain_core.tools import BaseTool from langgraph.func import task -from langgraph.types import interrupt from sqlalchemy import select from app.core.config import settings +from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.db.base import SessionLocal from app.db.models import Skill from app.engine.approval import read_decision from app.engine.context import NodeContext, NodeError -from app.engine.errors import describe_exception +from app.engine.replay import ask, once from app.engine.state import GraphState, message_text, template_context, thinking_text +from app.engine.toolcalls import ( + TOOL_MARKUP_ERROR, + TOOL_MARKUP_NUDGE, + ToolTimeout, + leaked_markup, + limit_fields, + limit_of, + markup_warning, + run_bounded, +) from app.providers import catalog from app.providers.factory import ( ModelSpec, @@ -27,6 +37,7 @@ from app.providers.factory import ( ) from app.tools.registry import ( ToolArgsError, + ToolBuildError, ToolContext, args_model_of, build_tools, @@ -124,6 +135,14 @@ def _usage_of(message: BaseMessage, model_id: str) -> dict[str, Any]: } +def _report_call(ctx: NodeContext, response: BaseMessage, model_id: str, started: float) -> dict[str, Any]: + """一次模型调用一条 llm.end。返回这一次的用量。""" + usage = _usage_of(response, model_id) + ctx.emit(EventType.LLM_END, model=model_id, + duration_ms=int((time.perf_counter() - started) * 1000), **usage) + return usage + + def explain_model_error(exc: Exception, model_id: str) -> str: """把供应商的原始报错翻成能照着做的一句话。 @@ -218,6 +237,8 @@ async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]: messages = await _build_messages(state, ctx) schema = ctx.cfg("output_schema") started = time.perf_counter() + #: 这个节点里每次模型调用的用量。正常只有一次;模型把工具调用写成文字时会多一次纠正 + spent: list[dict[str, Any]] = [] if schema: # 要结构化结果就走原生 structured output,这条路径拿不到 token 流 @@ -241,9 +262,23 @@ async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]: payload = value if isinstance(value, (dict, list)) else getattr(value, "model_dump", lambda: value)() output = {"data": payload, "text": json.dumps(payload, ensure_ascii=False, indent=2)} response: BaseMessage = AIMessage(content=output["text"]) + spent.append(_report_call(ctx, response, model_id, started)) else: response = await _invoke_streaming(model, messages, ctx, model_id) + spent.append(_report_call(ctx, response, model_id, started)) text = message_text(response) + if snippet := leaked_markup(text): + # 模型把工具调用当正文写了出来:这个节点压根没有工具,那段"调用"什么都没查到。 + # 带一句纠正再问一次;还这样就判失败,别让一堆标记当答案往下游走 + ctx.emit(EventType.LOG, level="warn", code="tool_markup_leak", + message=f"{markup_warning(snippet)},已提醒它重试一次") + retried = time.perf_counter() + response = await _invoke_streaming( + model, [*messages, response, HumanMessage(content=TOOL_MARKUP_NUDGE)], ctx, model_id) + spent.append(_report_call(ctx, response, model_id, retried)) + text = message_text(response) + if leaked_markup(text): + raise NodeError(ctx.node.id, TOOL_MARKUP_ERROR) reasoning = thinking_text(response) if not text and reasoning: # 思考型模型把额度全花在推理上了,正文是空的 —— 说清楚而不是抛个空结果给下游 @@ -252,14 +287,9 @@ async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]: code="empty_completion") output = {"text": text, "thinking": reasoning} - usage = _usage_of(response, model_id) elapsed = int((time.perf_counter() - started) * 1000) - ctx.emit( - EventType.LLM_END, - model=model_id, - duration_ms=elapsed, - **usage, - ) + usage = {key: sum(one[key] for one in spent) for key in spent[0]} + usage["cost_usd"] = round(usage["cost_usd"], 6) result = {**output, "model": model_id, "duration_ms": elapsed} updates: dict[str, Any] = { @@ -289,7 +319,11 @@ async def _resolve_tools(ctx: NodeContext, state: GraphState) -> list[BaseTool]: collection=ctx.cfg("collection") or ctx.run.collection, ) async with SessionLocal() as session: - return await build_tools(names, tool_ctx, session=session) + try: + return await build_tools(names, tool_ctx, session=session) + except ToolBuildError as e: + # 绑的工具自己坏了(参数定义写坏的自定义工具):说清是哪个、去哪里改 + raise NodeError(ctx.node.id, str(e)) from e SKIPPED_NOTE = ( @@ -333,6 +367,10 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: 这里没有用 prebuilt 的 create_react_agent,因为需要在循环内部做三件它不做的事: 每步工具调用都往画布推事件、对危险工具逐个弹人工审批、以及步数护栏。 + + 人工审批恢复时整个节点从头重放。模型调用和工具调用都包在 @task 里,重放时结果 + 取自 checkpoint,不重复计费、不重复执行;它们的事件也发在 task 里面,于是轨迹里 + 同一次调用只出现一回。写在 task 外面的事件一律经 replay.once 发,道理相同。 """ model_spec = _model_spec(ctx) async with SessionLocal() as session: @@ -354,12 +392,17 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: transcript: list[dict[str, Any]] = [] def _report(response: BaseMessage, started: float) -> None: - # 每次模型调用一条 llm.end:用量在节点跑的过程中就看得到。发在 task 里面, - # 节点因审批重放时 task 结果取自 checkpoint,这条不会重复 + # 每次模型调用一条 llm.end:用量在节点跑的过程中就看得到 ctx.emit(EventType.LLM_END, agent=ctx.node.title, model=model_id, duration_ms=int((time.perf_counter() - started) * 1000), **_usage_of(response, model_id)) + def _count(response: BaseMessage) -> None: + usage = _usage_of(response, model_id) + for key in ("input_tokens", "output_tokens", "calls"): + total_usage[key] += usage[key] + total_usage["cost_usd"] = round(total_usage["cost_usd"] + usage["cost_usd"], 6) + # @task 的返回值会进 checkpoint:人工审批导致节点重放时, # 之前已经完成的模型调用和工具执行不会重跑,也就不会重复计费。 @task @@ -379,23 +422,75 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: return response @task - async def tool_step(step: int, name: str, args: dict[str, Any]) -> str: + async def tool_step(step: int, name: str, args: dict[str, Any], call_id: str) -> dict[str, Any]: + # 失败也在这里收住、作为结果返回:失败的那次同样只执行一回,重放时不再重试 tool = tool_map[name] - result = await tool.ainvoke(args) - return result if isinstance(result, str) else json.dumps(result, ensure_ascii=False, default=str) + limit = limit_of(tool, name, args) + ctx.emit(EventType.TOOL_START, tool=name, args=args, call_id=call_id, **limit_fields(limit)) + started = time.perf_counter() + extra: dict[str, Any] = {} + try: + result = await run_bounded(tool.ainvoke(args), limit, name) + content = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False, default=str) + ok = True + except ToolTimeout as e: + # 到点就收回控制权,驱动的收尾在后台做完。模型拿到的是"等了多久、为什么放弃" + content, ok = str(e), False + extra = {"error": str(e), "timed_out": True} + except Exception as e: # noqa: BLE001 - 工具失败要喂回模型,让它自己纠错 + content = f"工具执行失败:{describe_exception(e)}" + schema = args_model_of(tool) + if isinstance(e, TypeError) and schema is not None: + # 签名对不上还能走到这儿,说明 schema 和函数本身不一致(工具的 bug)。 + # 模型改不了这个,但把参数表摊开至少让它别在同一个地方反复试 + content += f"\n{describe_args(schema, args)}" + ok = False + extra = {"error": content.splitlines()[0][:300], "detail": raw_detail(e)} + elapsed = int((time.perf_counter() - started) * 1000) + snapshot_id: str | None = None + if ok: + from app.core.artifact_store import put_json + + try: + snapshot_id = await put_json( + {"tool": name, "args": args, "result": content}, + kind="tool_snapshot", run_id=ctx.run.run_id, node_id=ctx.node.id, + ) + except Exception: # noqa: BLE001 + snapshot_id = None + ctx.emit( + EventType.TOOL_END if ok else EventType.TOOL_ERROR, + tool=name, + call_id=call_id, + duration_ms=elapsed, + preview=content[:2000], + artifact=snapshot_id, + **extra, + ) + return {"content": content, "ok": ok, "duration_ms": elapsed} final_text = "" + #: 模型把工具调用写成了文字:纠正过一次了没有 + nudged = False for step in range(max_steps): response = await llm_step(step, messages) messages.append(response) - usage = _usage_of(response, model_id) - for key in ("input_tokens", "output_tokens", "calls"): - total_usage[key] += usage[key] - total_usage["cost_usd"] = round(total_usage["cost_usd"] + usage["cost_usd"], 6) + _count(response) tool_calls = getattr(response, "tool_calls", None) or [] if not tool_calls: final_text = message_text(response) + if snippet := leaked_markup(final_text): + # 没有真实的调用,正文里却是一段调用标记:这一步什么都没查到。 + # 纠正一次;再来一次就是这个节点/模型根本调不了工具,判失败 + if nudged: + raise NodeError(ctx.node.id, TOOL_MARKUP_ERROR) + nudged = True + await once(ctx, EventType.LOG, level="warn", code="tool_markup_leak", + message=f"{markup_warning(snippet)},已提醒它重试一次") + messages.append(HumanMessage(content=TOOL_MARKUP_NUDGE)) + final_text = "" + continue break run_calls, deferred = split_tool_calls(tool_calls, parallel=parallel_tools) @@ -421,35 +516,25 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 不执行,把"它接受什么"原样喂回去。模型下一步照着改就行, # 这才是失败之后真正能自愈的重试——以前喂回去的是一句 # TypeError,里面既没有正确参数名也没有字段说明 - ctx.emit(EventType.TOOL_ERROR, tool=name, call_id=call_id, error=str(e)) + await once(ctx, EventType.TOOL_ERROR, tool=name, call_id=call_id, error=str(e)) messages.append(ToolMessage(content=str(e), tool_call_id=call_id)) transcript.append({"tool": name, "args": args, "ok": False, "result": str(e)}) continue if fix_note: - ctx.emit(EventType.LOG, level="warn", - message=f"工具 {name}:{fix_note}", code="tool_args_fixed") + await once(ctx, EventType.LOG, level="warn", + message=f"工具 {name}:{fix_note}", code="tool_args_fixed") if _needs_approval(ctx, tool_map[name], args): - ctx.emit( - EventType.HUMAN_REQUESTED, - mode="approve", - tool=name, - args=args, - title=f"Agent 想调用工具 {name}", - ) - decision = interrupt( - { - "kind": "tool_approval", - "node_id": ctx.node.id, - "tool": name, - "args": args, - "title": f"是否允许调用 {name}?", - } + decision = await ask( + ctx, + {"kind": "tool_approval", "node_id": ctx.node.id, "tool": name, "args": args, + "title": f"是否允许调用 {name}?"}, + mode="approve", tool=name, args=args, title=f"Agent 想调用工具 {name}", ) verdict = read_decision(decision) note = verdict.note - ctx.emit(EventType.HUMAN_RESOLVED, tool=name, approved=verdict.approved, note=note, - actor=ctx.actor()) + await once(ctx, EventType.HUMAN_RESOLVED, tool=name, approved=verdict.approved, + note=note, actor=ctx.actor()) if not verdict.approved: messages.append( ToolMessage( @@ -462,41 +547,10 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if verdict.args: args = verdict.args - ctx.emit(EventType.TOOL_START, tool=name, args=args, call_id=call_id) - started = time.perf_counter() - try: - content = await tool_step(step, name, args) - ok = True - except Exception as e: # noqa: BLE001 - 工具失败要喂回模型,让它自己纠错 - content = f"工具执行失败:{describe_exception(e)}" - if isinstance(e, TypeError) and schema is not None: - # 签名对不上还能走到这儿,说明 schema 和函数本身不一致(工具的 bug)。 - # 模型改不了这个,但把参数表摊开至少让它别在同一个地方反复试 - content += f"\n{describe_args(schema, args)}" - ok = False - elapsed = int((time.perf_counter() - started) * 1000) - snapshot_id: str | None = None - if ok: - from app.core.artifact_store import put_json - - try: - snapshot_id = await put_json( - {"tool": name, "args": args, "result": content}, - kind="tool_snapshot", run_id=ctx.run.run_id, node_id=ctx.node.id, - ) - except Exception: # noqa: BLE001 - snapshot_id = None - ctx.emit( - EventType.TOOL_END if ok else EventType.TOOL_ERROR, - tool=name, - call_id=call_id, - duration_ms=elapsed, - preview=content[:2000], - artifact=snapshot_id, - ) - transcript.append( - {"tool": name, "args": args, "ok": ok, "duration_ms": elapsed, "result": content[:4000]} - ) + outcome = await tool_step(step, name, args, call_id) + content = outcome["content"] + transcript.append({"tool": name, "args": args, "ok": outcome["ok"], + "duration_ms": outcome["duration_ms"], "result": content[:4000]}) messages.append(ToolMessage(content=content, tool_call_id=call_id)) # 没跑的那些排在执行过的后面,保持模型原本的调用顺序 @@ -506,7 +560,7 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: {"tool": call.get("name", ""), "args": call.get("args", {}) or {}, "skipped": True} ) else: - # 步数用完了。走到这儿一定意味着最后那步**要求了工具**(不要工具会 break), + # 步数用完了:最后那步要么要求了工具,要么是纠正之后还没来得及答。 # 也就是说模型从来没拿到过"说结论"的那一轮——它只是被掐断在半路。 # # 所以先补上那一轮:不给工具,让它基于已经查到的东西收口。这比把中间 @@ -520,14 +574,23 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: ))) try: response = await settle_step(messages) - usage = _usage_of(response, model_id) - for key in ("input_tokens", "output_tokens", "calls"): - total_usage[key] += usage[key] - total_usage["cost_usd"] = round(total_usage["cost_usd"] + usage["cost_usd"], 6) + _count(response) settled = message_text(response).strip() except Exception as e: # noqa: BLE001 - 收尾失败不能把已有成果一起赔进去 - ctx.emit(EventType.LOG, level="warn", - message=f"收尾轮没跑成:{describe_exception(e)}", code="settle_failed") + await once(ctx, EventType.LOG, level="warn", + message=f"收尾轮没跑成:{describe_exception(e)}", code="settle_failed") + + if snippet := leaked_markup(settled): + if not any(isinstance(m, AIMessage) and getattr(m, "tool_calls", None) for m in messages): + # 从头到尾一次真调用都没有,步数全耗在写标记上:还是那个「调不了工具」 + raise NodeError(ctx.node.id, TOOL_MARKUP_ERROR) + # 收尾轮拿的是没绑工具的模型,它还是写了一段调用标记:想接着查、没能收口。 + # 这不是「节点没绑工具」(前面的步数里工具是真调过的),不能套 TOOL_MARKUP_ERROR + # 判失败、把已经查到的一起丢掉——按收尾轮没说出话处理,取它之前说过的话 + await once(ctx, EventType.LOG, level="warn", code="tool_markup_leak", + message=f"{markup_warning(snippet)}:步数用完后的收尾轮仍想调用工具," + "没能给出结论") + settled = "" if settled: final_text = settled @@ -546,7 +609,8 @@ async def run_agent(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 管道文案冒充结论,比明说"没跑完"糟糕得多。 final_text = next( (text for m in reversed(messages) - if isinstance(m, AIMessage) and (text := message_text(m).strip())), + if isinstance(m, AIMessage) and (text := message_text(m).strip()) + and not leaked_markup(text)), "", ) hint = ( diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index 56894acb..786df515 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -10,12 +10,21 @@ from langgraph.errors import GraphRecursionError from langgraph.func import task from app.core.config import settings +from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.db.base import SessionLocal from app.db.models import Workflow from app.engine.context import NodeContext, NodeError -from app.engine.errors import describe_exception from app.engine.state import GraphState, message_text, template_context +from app.engine.toolcalls import ( + TOOL_MARKUP_NUDGE, + ToolTimeout, + leaked_markup, + limit_fields, + limit_of, + markup_warning, + run_bounded, +) from app.providers import catalog from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model from app.tools.registry import ( @@ -32,6 +41,10 @@ _MAX_DEPTH = 3 #: 调度者在 llm.end 里的 agent 名。成员用各自的名字 COORDINATOR = "调度者" +#: 成员把工具调用写成文字、纠正之后还是这样时,交给调度者的那句话 +MEMBER_MARKUP_FAILED = ("模型输出了工具调用的原始标记,但没有真正调用工具,这一步什么都没查到" + "(常见原因:这个成员没有绑定工具,或者模型、服务不支持工具调用)") + # -------------------------------------------------------------------------- # Supervisor:一个调度者 + 若干专家 @@ -52,6 +65,9 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: names = [a.get("name") or f"agent{i}" for i, a in enumerate(agents)] agent_map = {n: a for n, a in zip(names, agents)} max_rounds = min(int(ctx.cfg("max_rounds", 6) or 6), settings.max_agent_steps) + #: 轮数用完、调度者始终没判定完成时怎么办。fail:判失败;degrade:照样交付但标明降档。 + #: 缺省 fail——成员的原话不是结论,把它当成功交出去比失败更糟 + on_exhausted = "degrade" if ctx.cfg("on_exhausted") == "degrade" else "fail" goal = ctx.render_str(ctx.cfg("goal", "{{ last_message }}"), state) if not goal.strip(): @@ -115,11 +131,12 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: return {"input_tokens": i, "output_tokens": o, "cost_usd": round(catalog.estimate_cost(model_id, i, o), 6)} - def _accumulate(one: dict[str, Any]) -> None: - usage_total["input_tokens"] += one["input_tokens"] - usage_total["output_tokens"] += one["output_tokens"] - usage_total["calls"] += 1 - usage_total["cost_usd"] = round(usage_total["cost_usd"] + one["cost_usd"], 6) + def _accumulate(one: dict[str, Any], into: dict[str, Any] | None = None) -> None: + into = usage_total if into is None else into + into["input_tokens"] += one["input_tokens"] + into["output_tokens"] += one["output_tokens"] + into["calls"] += one.get("calls", 1) + into["cost_usd"] = round(into["cost_usd"] + one["cost_usd"], 6) def _report(who: str, model_id: str, one: dict[str, Any], t0: float) -> None: # 每次模型调用各发一条 llm.end,界面上的用量才能实时涨,而且分得清是谁花的。 @@ -127,24 +144,8 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: ctx.emit(EventType.LLM_END, agent=who, model=model_id, duration_ms=int((time.perf_counter() - t0) * 1000), **one) - @task - async def route(round_no: int, progress: str) -> dict[str, Any]: - prompt = ( - f"你是团队调度者。目标:\n{goal}\n\n" - f"可用成员:\n{roster}\n\n" - f"当前进展:\n{progress or '(还没有任何进展)'}\n\n" - "决定这一轮把什么任务交给谁,每个任务给一句明确的指令。\n\n" - f"**互不依赖的任务可以放在同一轮**(最多 {max_parallel} 个),它们会同时执行," - "总耗时按最慢的那个算。判断依据只有一条:**后一个任务需不需要看到前一个的结果**。\n" - " 可以同时派:「查 A 产品的资料」和「查 B 产品的资料」——两边互不相干\n" - " 不能同时派:「查资料」和「根据资料写报告」——后者要等前者\n" - " 不能同时派:「写初稿」和「校对初稿」——同上\n\n" - "同时派出去的成员看到的是**同一份进展快照**,谁也看不见谁这一轮干了什么。" - "拿不准是否独立时就分两轮派,代价只是慢一点;派错了则是两个人基于同样的" - "旧信息重复劳动,而这在结果里很难看出来。\n\n" - "目标已经达成时,done 填 true、assignments 给空数组。" - ) - t0 = time.perf_counter() + async def _decide(prompt: str, *, closing: bool = False) -> tuple[dict[str, Any], dict[str, Any]]: + """问调度者一次。返回 (决定, 这次调用的用量)。""" spent = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0} try: # include_raw:结构化结果之外把原始消息也要回来,用量在那条消息上。 @@ -161,19 +162,139 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if not isinstance(value, dict): value = json.loads(message_text(value)) except Exception: # noqa: BLE001 - # 不支持结构化输出的模型退回文本:从回复里认出一个成员名,按单人派 + # 不支持结构化输出的模型退回文本:从回复里认出一个成员名,按单人派。 + # 收尾判定时同理:回复里还点着哪个成员,就是还没完 reply = await supervisor_model.ainvoke(prompt) spent = _usage(reply, sup_model_id) raw = message_text(reply) picked = next((n for n in names if n in raw), None) - value = ({"assignments": [{"agent": picked, "instruction": raw[:500]}]} - if picked else {"assignments": [], "done": True, "reason": raw[:200]}) - # 在 task 里发:节点重放时 task 结果取自 checkpoint,这条不会重复 + if closing: + value = {"assignments": [], "done": picked is None, "reason": raw[:200]} + else: + value = ({"assignments": [{"agent": picked, "instruction": raw[:500]}]} + if picked else {"assignments": [], "done": True, "reason": raw[:200]}) + return (value if isinstance(value, dict) else {}), spent + + def _batch_of(decision: dict[str, Any]) -> list[tuple[str, str]]: + # 只留认得出的成员,并按上限截断。模型偶尔会重复派同一个人或者编一个 + # 不存在的名字——这两种在并发里都是纯浪费 + batch: list[tuple[str, str]] = [] + seen: set[str] = set() + for item in decision.get("assignments") or []: + name = str((item or {}).get("agent") or "") + if name in agent_map and name not in seen: + seen.add(name) + batch.append((name, str(item.get("instruction") or goal))) + if len(batch) >= max_parallel: + break + return batch + + def _finished(round_no: int, reason: str, **extra: Any) -> None: + # agents / done 是结构化字段,界面照着它渲染。以前只有一句中文 + # 消息串,解码层得拿正则去拆「调度 → X(理由)」——文案一改就散架 + ctx.emit(EventType.LOG, level="info", round=round_no, agents=[], done=True, + message="调度 → 结束协作" + (f"({reason})" if reason else ""), **extra) + + # 调度者决策前后各一条事件(它常常占掉团队节点大半的时间,以前是黑盒),和派活的 + # 那条日志一起都在 task 里发:「接着跑」一个失败的团队节点时整个节点重放,前几轮 + # 的决定取自 checkpoint,这些事件不该在时间线上再出现一遍 + @task + async def route(round_no: int, progress: str) -> dict[str, Any]: + ctx.emit(EventType.AGENT_ROUTE_START, round=round_no) + prompt = ( + f"你是团队调度者。目标:\n{goal}\n\n" + f"可用成员:\n{roster}\n\n" + f"当前进展:\n{progress or '(还没有任何进展)'}\n\n" + "决定这一轮把什么任务交给谁,每个任务给一句明确的指令。\n\n" + f"**互不依赖的任务可以放在同一轮**(最多 {max_parallel} 个),它们会同时执行," + "总耗时按最慢的那个算。判断依据只有一条:**后一个任务需不需要看到前一个的结果**。\n" + " 可以同时派:「查 A 产品的资料」和「查 B 产品的资料」——两边互不相干\n" + " 不能同时派:「查资料」和「根据资料写报告」——后者要等前者\n" + " 不能同时派:「写初稿」和「校对初稿」——同上\n\n" + "同时派出去的成员看到的是**同一份进展快照**,谁也看不见谁这一轮干了什么。" + "拿不准是否独立时就分两轮派,代价只是慢一点;派错了则是两个人基于同样的" + "旧信息重复劳动,而这在结果里很难看出来。\n\n" + "目标已经达成时,done 填 true、assignments 给空数组。" + ) + t0 = time.perf_counter() + value, spent = await _decide(prompt) _report(COORDINATOR, sup_model_id, spent, t0) + + batch = _batch_of(value) + finishing = bool(value.get("done")) or not batch + reason = str(value.get("reason", "")) + ctx.emit(EventType.AGENT_ROUTE_END, round=round_no, + duration_ms=int((time.perf_counter() - t0) * 1000), + agents=[] if finishing else [n for n, _ in batch], + parallel=0 if finishing else len(batch), done=finishing, reason=reason) + if finishing: + _finished(round_no, reason) + else: + who = "、".join(n for n, _ in batch) + ctx.emit( + EventType.LOG, level="info", round=round_no, parallel=len(batch), + agents=[n for n, _ in batch], done=False, reason=reason, + message=(f"调度 → {who}" + (f"({reason})" if reason else "") + + (f" · {len(batch)} 人同时进行" if len(batch) > 1 else "")), + ) return {"decision": value, "usage": spent} + # 最后一轮派出去的成员(常常就是定稿的那个)交回来之后,调度者还没看过它的产出。 + # 以前轮数一到就算用完:步数恰好等于最多轮数的正常流水线(查数 → 撰稿,2 轮)也会 + # 被判成没完成。补这一次只判定、不派活的决定,它说完成了才算完成 @task - async def work(round_no: int, name: str, instruction: str, progress: str) -> dict[str, Any]: + async def verdict(round_no: int, progress: str) -> dict[str, Any]: + ctx.emit(EventType.AGENT_ROUTE_START, round=round_no, closing=True) + prompt = ( + f"你是团队调度者。目标:\n{goal}\n\n" + f"当前进展:\n{progress or '(还没有任何进展)'}\n\n" + f"轮数已经用完({max_rounds} 轮),不能再派任何任务。只判断一件事:按上面的进展," + "目标是不是已经达成、能不能把最后一个成员的产出作为结论交出去。\n" + "达成了 done 填 true;没达成 done 填 false,并在 reason 里说清还缺什么。" + "assignments 给空数组。" + ) + t0 = time.perf_counter() + value, spent = await _decide(prompt, closing=True) + _report(COORDINATOR, sup_model_id, spent, t0) + done = value.get("done") is True + reason = str(value.get("reason", "")) + ctx.emit(EventType.AGENT_ROUTE_END, round=round_no, + duration_ms=int((time.perf_counter() - t0) * 1000), + agents=[], parallel=0, done=done, reason=reason, closing=True) + if done: + _finished(round_no, reason, closing=True) + return {"done": done, "reason": reason, "usage": spent} + + # 成员的开始、结束两条事件也在 task 里发,理由同 route。失败也在 task 里收住、作为 + # 结果缓存:重放时这一步照旧是失败,不会换一个结果。用量随结果带出来再记账——在 + # task 里直接记进 usage_total 的话,重放取了缓存,这一笔就没了 + @task + async def work(round_no: int, name: str, instruction: str, progress: str, + parallel: int = 1) -> dict[str, Any]: + ctx.emit(EventType.AGENT_STEP_START, agent=name, instruction=instruction[:300], + round=round_no, parallel=parallel) + tally = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "calls": 0} + t0 = time.perf_counter() + try: + out: dict[str, Any] = await _member(name, instruction, progress, tally) + except asyncio.CancelledError: + raise + except BaseException as exc: # noqa: BLE001 - 一个专家挂了不该拖垮整轮 + # 把失败当成它的产出交回调度者,让它决定改派还是收工 + why = describe_exception(exc) + text = f"({name} 执行失败:{why})" + out = {"text": text, "tool_calls": [], "failed": True, "error": why} + ctx.emit(EventType.LOG, level="warn", round=round_no, + message=f"{name} 这一轮失败了:{text}") + each_ms = int((time.perf_counter() - t0) * 1000) + # failed / error 让矩阵把这一格画成失败,而不是一个"完成"的格子里写着失败原因 + failure = {"failed": True, "error": out.get("error") or ""} if out.get("failed") else {} + ctx.emit(EventType.AGENT_STEP_END, agent=name, duration_ms=each_ms, + round=round_no, parallel=parallel, preview=out["text"][:500], **failure) + return {**out, "duration_ms": each_ms, "usage": tally} + + async def _member(name: str, instruction: str, progress: str, + tally: dict[str, Any]) -> dict[str, Any]: cfg = agent_map[name] async with SessionLocal() as session: model, model_id = await get_chat_model( @@ -202,20 +323,37 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: calls: list[dict[str, Any]] = [] # 和 agent 节点同一个硬顶:成员上配多少步都过不去 max_agent_steps max_steps = min(int(cfg.get("max_steps") or 4), settings.max_agent_steps) + nudged = False for _ in range(max_steps): t0 = time.perf_counter() reply = await bound.ainvoke(messages) spent = _usage(reply, model_id) - _accumulate(spent) + _accumulate(spent, tally) _report(name, model_id, spent, t0) messages.append(reply) tool_calls = getattr(reply, "tool_calls", None) or [] if not tool_calls: - return {"text": message_text(reply), "tool_calls": calls} + text = message_text(reply) + snippet = leaked_markup(text) + if not snippet: + return {"text": text, "tool_calls": calls} + # 把工具调用写成了文字:这一步什么都没查到。纠正一次,还这样就记为失败, + # 原因交给调度者——以前这段标记会被当成这个成员的结论交上去 + if nudged: + return {"text": f"({name} 这一步失败:{MEMBER_MARKUP_FAILED})", + "tool_calls": calls, "failed": True, "error": MEMBER_MARKUP_FAILED} + nudged = True + ctx.emit(EventType.LOG, level="warn", code="tool_markup_leak", + message=f"{markup_warning(snippet, name)},已提醒它重试一次") + messages.append(HumanMessage(content=TOOL_MARKUP_NUDGE)) + continue for call in tool_calls: tname = call.get("name", "") targs = call.get("args", {}) or {} cid = call.get("id") or f"{name}-{tname}" + limit = None + failure: dict[str, Any] = {} + started = time.perf_counter() if tname not in tool_map: ctx.emit(EventType.TOOL_START, tool=tname, args=targs, agent=name, call_id=cid) content = f"错误:没有名为 {tname} 的工具" @@ -229,7 +367,9 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: except ToolArgsError as e: args_error = str(e) # 纠正后的参数才是真正要执行的那份,tool.start 要发它 - ctx.emit(EventType.TOOL_START, tool=tname, args=targs, agent=name, call_id=cid) + limit = limit_of(tool_map[tname], tname, targs) + ctx.emit(EventType.TOOL_START, tool=tname, args=targs, agent=name, call_id=cid, + **limit_fields(limit)) if fix_note: ctx.emit(EventType.LOG, level="warn", message=f"工具 {tname}:{fix_note}", code="tool_args_fixed") @@ -250,11 +390,17 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: message=f"{name} 想调用 {tname},需要人工确认,协作节点里不执行") else: try: - raw = await tool_map[tname].ainvoke(targs) + raw = await run_bounded(tool_map[tname].ainvoke(targs), limit, tname) content = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False, default=str) + except ToolTimeout as e: + content = str(e) + failure = {"error": content, "timed_out": True} except Exception as e: # noqa: BLE001 content = f"工具失败:{describe_exception(e)}" - ctx.emit(EventType.TOOL_END, tool=tname, agent=name, call_id=cid, preview=content[:1500]) + failure = {"error": content, "detail": raw_detail(e)} + ctx.emit(EventType.TOOL_ERROR if failure else EventType.TOOL_END, tool=tname, + agent=name, call_id=cid, preview=content[:1500], + duration_ms=int((time.perf_counter() - started) * 1000), **failure) calls.append({"tool": tname, "args": targs, "result": content[:2000]}) messages.append(ToolMessage(content=content, tool_call_id=cid)) @@ -270,14 +416,25 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: t0 = time.perf_counter() settled = await model.ainvoke(messages) spent = _usage(settled, model_id) - _accumulate(spent) + _accumulate(spent, tally) _report(name, model_id, spent, t0) text = message_text(settled).strip() except Exception as e: # noqa: BLE001 - 收尾失败不能把已有的过程一起赔进去 ctx.emit(EventType.LOG, level="warn", code="settle_failed", message=f"{name} 的收尾轮没跑成:{describe_exception(e)}") text = next((t for m in reversed(messages) - if isinstance(m, AIMessage) and (t := message_text(m).strip())), "") + if isinstance(m, AIMessage) and (t := message_text(m).strip()) + and not leaked_markup(t)), "") + if snippet := leaked_markup(text): + if not calls: + return {"text": f"({name} 这一步失败:{MEMBER_MARKUP_FAILED})", + "tool_calls": calls, "failed": True, "error": MEMBER_MARKUP_FAILED} + # 真调过工具,只是收尾轮还想接着查:取它之前说过的话,和 agent 节点一样 + ctx.emit(EventType.LOG, level="warn", code="tool_markup_leak", + message=f"{markup_warning(snippet, name)}:步数用完后的收尾轮仍想调用工具") + text = next((t for m in reversed(messages) + if isinstance(m, AIMessage) and (t := message_text(m).strip()) + and not leaked_markup(t)), "") ctx.emit(EventType.LOG, level="warn", code="step_limit_settled", message=f"{name} 用满了 {max_steps} 步,结论基于已经查到的部分") return {"text": text or f"({name} 用满了 {max_steps} 步,没有给出结论)", "tool_calls": calls} @@ -285,54 +442,26 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: progress_lines: list[str] = [] final_text = "" rounds_meta: list[dict[str, Any]] = [] + #: 调度者最后一轮给的理由,以及派过活的成员。轮数用完时报错要说清卡在哪、谁从没上过场 + last_reason = "" + dispatched: set[str] = set() + finished = False for round_no in range(max_rounds): progress = "\n\n".join(progress_lines) - # 调度者决策前后各一条事件。它常常占掉团队节点大半的时间,以前是黑盒 - ctx.emit(EventType.AGENT_ROUTE_START, round=round_no) - route_t0 = time.perf_counter() routed = await route(round_no, progress) - route_ms = int((time.perf_counter() - route_t0) * 1000) decision = routed.get("decision", routed) if isinstance(routed, dict) else {} + decision = decision if isinstance(decision, dict) else {} _accumulate(routed.get("usage") or _usage(None, sup_model_id)) reason = str(decision.get("reason", "")) + last_reason = reason or last_reason - # 只留认得出的成员,并按上限截断。模型偶尔会重复派同一个人或者编一个 - # 不存在的名字——这两种在并发里都是纯浪费 - batch: list[tuple[str, str]] = [] - seen: set[str] = set() - for item in decision.get("assignments") or []: - name = str((item or {}).get("agent") or "") - if name in agent_map and name not in seen: - seen.add(name) - batch.append((name, str(item.get("instruction") or goal))) - if len(batch) >= max_parallel: - break - - finishing = bool(decision.get("done")) or not batch - ctx.emit(EventType.AGENT_ROUTE_END, round=round_no, duration_ms=route_ms, - agents=[] if finishing else [n for n, _ in batch], - parallel=0 if finishing else len(batch), done=finishing, reason=reason) - - if finishing: - # agents / done 是结构化字段,界面照着它渲染。以前只有一句中文 - # 消息串,解码层得拿正则去拆「调度 → X(理由)」——文案一改就散架 - ctx.emit(EventType.LOG, level="info", round=round_no, - agents=[], done=True, - message="调度 → 结束协作" + (f"({reason})" if reason else "")) + batch = _batch_of(decision) + if bool(decision.get("done")) or not batch: final_text = progress_lines[-1].split("】", 1)[-1] if progress_lines else "" + finished = True break - who = "、".join(n for n, _ in batch) - ctx.emit( - EventType.LOG, level="info", round=round_no, parallel=len(batch), - agents=[n for n, _ in batch], done=False, reason=reason, - message=(f"调度 → {who}" + (f"({reason})" if reason else "") - + (f" · {len(batch)} 人同时进行" if len(batch) > 1 else "")), - ) - - for name, instruction in batch: - ctx.emit(EventType.AGENT_STEP_START, agent=name, instruction=instruction[:300], - round=round_no, parallel=len(batch)) + dispatched.update(n for n, _ in batch) # 并发执行。它们拿到的是**同一份** progress 快照——这正是"任务必须 # 互不依赖"的技术含义,也是上面提示词里那几条反例的由来。 @@ -341,32 +470,23 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 时间",那个数必须是量出来的。按 N×墙钟 推算等于假设每个人都跑满了 # 最慢那条,而实际上快的那个可能只花了三分之一。 # - # 结束事件也在这里一完成就发,不等整轮 gather:以前先做完的成员要一直 - # 显示"进行中",直到最慢的那个做完——矩阵答不了"谁还在跑" - async def _timed(name: str, instruction: str, parallel: int) -> tuple[str, Any, int]: - t0 = time.perf_counter() - try: - out: Any = await work(round_no, name, instruction, progress) - except asyncio.CancelledError: - raise - except BaseException as exc: # noqa: BLE001 - 一个专家挂了不该拖垮整轮 - # 把失败当成它的产出交回调度者,让它决定改派还是收工 - text = f"({name} 执行失败:{describe_exception(exc)})" - out = {"text": text, "tool_calls": []} - ctx.emit(EventType.LOG, level="warn", round=round_no, - message=f"{name} 这一轮失败了:{text}") - each_ms = int((time.perf_counter() - t0) * 1000) - ctx.emit(EventType.AGENT_STEP_END, agent=name, duration_ms=each_ms, - round=round_no, parallel=parallel, preview=out["text"][:500]) - return name, out, each_ms + # 结束事件也在各自的 task 里一完成就发,不等整轮 gather:以前先做完的成员要 + # 一直显示"进行中",直到最慢的那个做完——矩阵答不了"谁还在跑" + async def _one(name: str, instruction: str, parallel: int) -> dict[str, Any]: + return await work(round_no, name, instruction, progress, parallel) started = time.perf_counter() - results = await asyncio.gather(*(_timed(n, i, len(batch)) for n, i in batch)) + results = await asyncio.gather(*(_one(n, i, len(batch)) for n, i in batch)) wall_ms = int((time.perf_counter() - started) * 1000) # 进展仍按派活顺序拼:它是下一轮调度者读的输入,语义不随谁先回来而变 sum_ms = 0 - for (name, instruction), (_, outcome, each_ms) in zip(batch, results): + for (name, instruction), result in zip(batch, results): + outcome = dict(result) + spent = outcome.pop("usage", None) + if spent: + _accumulate(spent) + each_ms = int(outcome.pop("duration_ms", 0) or 0) sum_ms += each_ms progress_lines.append(f"【{name}】{outcome['text']}") transcript.append( @@ -380,6 +500,32 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "parallel": len(batch), "wall_ms": wall_ms, "sum_ms": sum_ms, }) + if not finished and progress_lines: + judged = await verdict(max_rounds, "\n\n".join(progress_lines)) + _accumulate(judged.get("usage") or _usage(None, sup_model_id)) + last_reason = str(judged.get("reason") or "") or last_reason + if judged.get("done"): + final_text = progress_lines[-1].split("】", 1)[-1] + finished = True + + exhausted: dict[str, Any] = {} + if not finished: + # 轮数用完,调度者一次都没说「完成」。这时手上只有最后一个成员的原话—— + # 以前它就被当作团队的结论交了出去:真实运行里是一段没执行的工具调用标记, + # 而负责定稿的成员一次都没被派到 + never = [n for n in names if n not in dispatched] + summary = (f"协作团队用完 {max_rounds} 轮仍未完成:" + f"{last_reason or '调度者没有给出理由'}") + if never: + summary += f"。一次都没被派到的成员:{'、'.join(never)}" + if on_exhausted == "fail": + raise NodeError(ctx.node.id, f"{summary}。先看成员有没有绑定要用的工具,再调大「最多" + "轮数」;也可以把「用完轮数时」改成降档交付") + exhausted = {"exhausted": True, "exhausted_reason": last_reason, + "never_dispatched": never} + ctx.emit(EventType.LOG, level="warn", code="team_exhausted", + message=f"{summary}。按降档交付:成果是成员最后的原话,不是调度者认可的结论") + usage_total["total_tokens"] = usage_total["input_tokens"] + usage_total["output_tokens"] # 并行省下的时间:各轮"串行本该花的"减去"实际花的"。只有并发过才有差值 saved_ms = sum(r["sum_ms"] - r["wall_ms"] for r in rounds_meta) @@ -391,6 +537,7 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "agents": names, "schedule": rounds_meta, "parallel_saved_ms": saved_ms, + **exhausted, } updates: dict[str, Any] = { "nodes": {ctx.node.id: result}, diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index 10690ead..2950aa04 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -4,18 +4,19 @@ import json import time from typing import Any -from langgraph.types import interrupt - +from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.db.base import SessionLocal from app.engine.approval import read_decision from app.engine.context import NodeContext, NodeError -from app.engine.errors import describe_exception, raw_detail +from app.engine.replay import ask, once from app.engine.state import GraphState +from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounded from app.sandbox.base import SandboxLimits from app.sandbox.manager import sandbox_manager from app.tools.registry import ( ToolArgsError, + ToolBuildError, ToolContext, build_tools, call_is_dangerous, @@ -34,16 +35,20 @@ def _tool_ctx(ctx: NodeContext) -> ToolContext: ) -async def _is_dangerous(name: str, args: dict[str, Any], ctx: NodeContext) -> bool: - """内置工具看 ToolSpec;数据源这类动态工具得先建出来,才问得到这一次危不危险。 +async def _dynamic_tool(name: str, ctx: NodeContext) -> Any: + """数据源、MCP、自定义这类动态工具得先建出来,才问得到它危不危险、时限多长。 - 审批通过后节点会整个重放,这里会再算一遍——所以它必须只取决于 name 和 args。 + 内置工具的这两件事看 ToolSpec 和参数就够,不用建。 """ if get_spec(name) is not None: - return call_is_dangerous(None, name, args) + return None async with SessionLocal() as session: - tools = await build_tools([name], _tool_ctx(ctx), session=session) - return call_is_dangerous(tools[0] if tools else None, name, args) + try: + tools = await build_tools([name], _tool_ctx(ctx), session=session) + except ToolBuildError as e: + # 工具自己的配置写坏了:说清是哪个、去哪里改,不再往下走到 call_tool 里炸 + raise NodeError(ctx.node.id, str(e)) from e + return tools[0] if tools else None async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: @@ -56,24 +61,27 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if not isinstance(args, dict): raise NodeError(ctx.node.id, "工具参数必须是对象") + # 审批通过后节点会整个重放、再算一遍要不要问人,所以这里只能取决于 name 和 args。 # 节点没配时取全局设置(「危险工具默认需要人工确认」),见 NodeContext.approval_mode + tool = await _dynamic_tool(name, ctx) approval = ctx.approval_mode() - if approval == "always" or (approval == "dangerous" and await _is_dangerous(name, args, ctx)): - ctx.emit(EventType.HUMAN_REQUESTED, mode="approve", tool=name, args=args, - title=f"是否允许调用 {name}?") - decision = read_decision(interrupt( + if approval == "always" or (approval == "dangerous" and call_is_dangerous(tool, name, args)): + decision = read_decision(await ask( + ctx, {"kind": "tool_approval", "node_id": ctx.node.id, "tool": name, "args": args, - "title": f"是否允许调用 {name}?"} + "title": f"是否允许调用 {name}?"}, + mode="approve", tool=name, args=args, title=f"是否允许调用 {name}?", )) if decision.args is not None: args = decision.args - ctx.emit(EventType.HUMAN_RESOLVED, tool=name, approved=decision.approved, - note=decision.note, actor=ctx.actor()) + await once(ctx, EventType.HUMAN_RESOLVED, tool=name, approved=decision.approved, + note=decision.note, actor=ctx.actor()) if not decision.approved: raise NodeError(ctx.node.id, f"用户拒绝执行工具 {name}" + (f":{decision.note}" if decision.note else "")) - ctx.emit(EventType.TOOL_START, tool=name, args=args) + limit = limit_of(tool, name, args) + ctx.emit(EventType.TOOL_START, tool=name, args=args, **limit_fields(limit)) started = time.perf_counter() def _fixed(note: str) -> None: @@ -82,9 +90,20 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: ctx.emit(EventType.LOG, level="warn", message=f"工具 {name}:{note}。请到节点里改正。", code="tool_args_fixed") - try: + async def _call() -> Any: async with SessionLocal() as session: - result = await call_tool(name, args, _tool_ctx(ctx), session=session, on_fix=_fixed) + return await call_tool(name, args, _tool_ctx(ctx), session=session, on_fix=_fixed) + + try: + result = await run_bounded(_call(), limit, name) + except ToolTimeout as e: + # 到点就收回控制权,驱动的收尾在后台做完。报错里写的是实际等了多久 + elapsed = int((time.perf_counter() - started) * 1000) + ctx.emit(EventType.TOOL_ERROR, tool=name, error=str(e), timed_out=True, duration_ms=elapsed) + if ctx.cfg("fail_fast", True): + raise NodeError(ctx.node.id, f"{e}。数据量大就在参数里缩小范围;对方偶尔卡住的话," + "稍后「接着跑」即可") from e + result = {"error": str(e), "timed_out": True} except KeyError as e: # str(KeyError) 是带引号的 repr,直接拼会多出一对引号 reason = e.args[0] if e.args else f"找不到工具 {name!r}" @@ -144,16 +163,16 @@ async def run_code(state: GraphState, ctx: NodeContext) -> dict[str, Any]: ) if ctx.cfg("approval", "never") == "always": - ctx.emit(EventType.HUMAN_REQUESTED, mode="approve", title="是否执行这段代码?", - code=code[:4000], language=language) - decision = read_decision(interrupt( + decision = read_decision(await ask( + ctx, {"kind": "code_approval", "node_id": ctx.node.id, "code": code, - "language": language, "title": "是否执行这段代码?"} + "language": language, "title": "是否执行这段代码?"}, + mode="approve", title="是否执行这段代码?", code=code[:4000], language=language, )) if decision.code: code = decision.code # 允许人工改完再跑 - ctx.emit(EventType.HUMAN_RESOLVED, approved=decision.approved, note=decision.note, - actor=ctx.actor()) + await once(ctx, EventType.HUMAN_RESOLVED, approved=decision.approved, note=decision.note, + actor=ctx.actor()) if not decision.approved: raise NodeError(ctx.node.id, "用户拒绝执行代码" + (f":{decision.note}" if decision.note else "")) diff --git a/backend/app/engine/replay.py b/backend/app/engine/replay.py new file mode 100644 index 00000000..46e0895c --- /dev/null +++ b/backend/app/engine/replay.py @@ -0,0 +1,71 @@ +"""节点重放时,已经发生过的事不在轨迹里再发生一次。 + +LangGraph 恢复一个停在 interrupt 上的节点,是把节点函数从头再跑一遍:前面几次 +interrupt() 按顺序直接返回已经给过的答复,@task 包住的调用直接取 checkpoint 里的 +结果。执行层面不会重复——agent 节点的模型调用、工具调用都在 task 里,副作用只发生 +一次。可写在 task 外面的 ctx.emit 每重放一次就再发一遍:运行 fac480ff 的轨迹里, +同一个 call_id 的 python_exec 有两对 tool.start / tool.end,审批请求也问了两回, +签批人还按最后一次恢复的人记。看轨迹的人只能照字面理解成「执行了两次」。 +「接着跑」一个失败的节点是同一回事。 + +所以要落进轨迹、又写在 task 外面的事,经这里发:它本身就是一次 task,第一次真正 +走到这里时发出,重放时结果取自 checkpoint、不再发。 +""" +from __future__ import annotations + +from typing import Any + +from langgraph.config import get_config +from langgraph.func import task +from langgraph.types import interrupt + +from app.core.events import EventType +from app.engine.context import NodeContext + +#: 重放协议的版本,记在每个 checkpoint 的 metadata 里。 +#: +#: LangGraph 认 task 的缓存靠「函数名 + 节点里第几次调用」。这一版多了 once / ask 的 +#: 记录 task,agent 的 tool_step 也换了位置和返回值:升级前停下的断点照新代码重放, +#: 节点里已经执行过的工具、模型调用对不上旧的位置,会再执行一次(副作用翻倍),缓存 +#: 下来的旧返回值还可能直接对不上形状。恢复前先看版本,对不上又有这种风险就拒绝 +#: (见 runner 的 _stale_replay)。以后再改节点里 task 的调用顺序,把它加一 +PROTOCOL = 2 +PROTOCOL_KEY = "agentlab_replay" + + +def protocol_of(metadata: Any) -> int: + """checkpoint 是按哪一版重放协议写的。加这个标记之前的都算 1。""" + value = (metadata or {}).get(PROTOCOL_KEY) if isinstance(metadata, dict) else None + return int(value) if isinstance(value, int) else 1 + + +def _in_graph() -> bool: + try: + get_config() + except RuntimeError: # 不在图执行上下文里(单独调用执行器的测试) + return False + return True + + +async def once(ctx: NodeContext, event_type: EventType | str, **data: Any) -> None: + """发一条事件,节点重放时不再发。""" + if not _in_graph(): + ctx.emit(event_type, **data) + return + + @task + async def record(kind: str) -> bool: + ctx.emit(kind, **data) + return True + + await record(str(event_type)) + + +async def ask(ctx: NodeContext, payload: dict[str, Any], **request: Any) -> Any: + """请人审批:human.requested 只在第一次走到这里时发,然后停下等答复。 + + 返回答复。答复读完之后由调用方用 once() 发 human.resolved——那一条也只该在 + 答复真正到达的那次执行里出现。 + """ + await once(ctx, EventType.HUMAN_REQUESTED, **request) + return interrupt(payload) diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index 0d318bc1..dc6b5fb7 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -7,6 +7,7 @@ from typing import Any from app.core.events import EventType from app.engine.issuance import extract_numbers, trace_numbers +from app.engine.toolcalls import leaked_markup # -------------------------------------------------------------------------- # 跑完之后的复核层。 @@ -70,6 +71,13 @@ WARN_CODES: dict[str, tuple[str, str]] = { "validate_retry": ("validate_retry", DEGRADED), "sandbox_reset": ("sandbox_reset", DEGRADED), "retrieve_degraded": ("retrieve_degraded", DEGRADED), + # 模型把工具调用写成了文字。纠正一次好了的,答案是真查过的,只记缺口; + # 没好的那次节点已经判失败,node_failed 会按 BROKEN 算 + "tool_markup_leak": ("tool_markup_leak", DEGRADED), + # 协作团队用完轮数、按降档交付:交来的是成员原话,不是调度者认可的结论 + "team_exhausted": ("team_exhausted", BROKEN), + # 校验节点的修复编出了原文没有的值,这次修复被作废 + "repair_invented": ("repair_invented", DEGRADED), } # 这些 kind 重跑一次大概率能好:原因说得清,且不是「问题本身没问明白」。 @@ -178,6 +186,9 @@ def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list add("empty_output", "这次运行没有产出任何成果内容", BROKEN) elif any(mark in text for mark in _PLACEHOLDER_MARKS): add("placeholder_output", "交出来的是一句内部流程说明,不是对问题的回答", BROKEN) + elif leaked_markup(text): + # 老运行、或者绕过了节点检查的路径:成果本身就是一段没执行的工具调用标记 + add("markup_output", "交出来的是一段没有执行的工具调用标记,不是对问题的回答", BROKEN) return signals diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index 1af49c1f..bb8487e4 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -5,6 +5,7 @@ import contextlib import json import logging import time +from collections.abc import Callable from datetime import datetime, timezone from typing import Any @@ -15,13 +16,14 @@ from sqlalchemy import select, update from app.core.bus import bus from app.core.config import settings +from app.core.errors import describe_exception, raw_detail from app.core.events import EventType, RunEventModel from app.db.base import SessionLocal from app.db.models import Approval, Run, RunEvent, Workflow from app.engine.compiler import compile_graph, initial_state from app.engine.context import NodeError, RunContext -from app.engine.errors import describe_exception, raw_detail -from app.engine.schema import GraphSpec, loop_steps, topology_of, validate_graph +from app.engine.replay import PROTOCOL, PROTOCOL_KEY, protocol_of +from app.engine.schema import GraphSpec, NodeType, loop_steps, topology_of, validate_graph logger = logging.getLogger(__name__) @@ -42,6 +44,8 @@ class RunManager: self._closing = False #: 已经跑完、正在落状态和封存的运行。关停不去打断它们 self._finalizing: set[str] = set() + #: 谁停的:取消执行任务时记下,run.cancelled 和关掉的审批上写得出人 + self._stopped_by: dict[str, str | None] = {} # ---------------- 生命周期 ---------------- @@ -111,16 +115,7 @@ class RunManager: persist: bool = True, ts: float | None = None, ) -> None: - event = RunEventModel( - run_id=run_id, - seq=bus.next_seq(run_id), - type=EventType(str(event_type)), - node_id=node_id, - data=data or {}, - # 节点里发出的事件带着它发生的时刻。以前一律取转发时刻:并行的成员 - # 先做完的那条也要排到整轮结束才被转发,时间线上的耗时就全错了 - **({"ts": ts} if ts else {}), - ) + event = self._event(run_id, event_type, node_id=node_id, data=data, ts=ts) # 先落库,再广播。顺序反过来会漏事件,而且是结构性的漏: # # 订阅者的完整性靠"读历史 + 接实时"两段拼起来。它先订阅、再读历史, @@ -133,22 +128,30 @@ class RunManager: # 一半几率就这么没了——界面表现为某个节点永远在转圈。 if persist and event.type not in _EPHEMERAL: async with SessionLocal() as session: - session.add( - RunEvent( - run_id=run_id, - seq=event.seq, - type=str(event.type), - node_id=event.node_id, - ts=event.ts, - data=event.data, - ) - ) - await session.execute( - update(Run).where(Run.id == run_id).values(last_seq=event.seq) - ) + await _stage(session, event) await session.commit() await bus.publish(event) + @staticmethod + def _event( + run_id: str, + event_type: EventType | str, + *, + node_id: str | None = None, + data: dict[str, Any] | None = None, + ts: float | None = None, + ) -> RunEventModel: + return RunEventModel( + run_id=run_id, + seq=bus.next_seq(run_id), + type=EventType(str(event_type)), + node_id=node_id, + data=data or {}, + # 节点里发出的事件带着它发生的时刻。以前一律取转发时刻:并行的成员 + # 先做完的那条也要排到整轮结束才被转发,时间线上的耗时就全错了 + **({"ts": ts} if ts else {}), + ) + async def note( self, run_id: str, event_type: EventType | str, *, node_id: str | None = None, **data: Any, @@ -256,6 +259,11 @@ class RunManager: "这次运行没有可恢复的断点(很可能重启前还没真正开始跑)。" "请重新发起一次运行,而不是恢复——继续下去只会用默认值跑出一份假结果。" ) + if stale := await _stale_replay(run_id, spec, snapshot): + raise ValueError( + f"这次运行是升级前停在「{stale}」上的,引擎记录断点的方式已经变了:现在恢复," + f"「{stale}」里已经执行过的工具会再执行一次。请放弃这次运行,重新发起" + ) answers: dict[str, Any] = {} async with SessionLocal() as session: @@ -283,14 +291,21 @@ class RunManager: now = datetime.now(timezone.utc) if target is not None: - target.status = "answered" # 已回复,但还没提交给引擎 # 存全文,不能用 _safe 截:多条审批并存时,交给引擎的就是这一份 # (见下面 answers)。截过的版本会把人工改过的长稿砍掉一截再送进节点 full = json.loads(json.dumps(response, ensure_ascii=False, default=str)) - target.response = full if isinstance(full, dict) else {"value": full} - target.resolved_at = now - if actor: - target.resolved_by = actor + # 条件更新:上面读到它还是 pending,到这里之间可能已经被放弃运行关掉 + # (或者另一个人先批了)。无条件写回去,会把关掉的审批又改成已回复 + marked = await session.execute( + update(Approval) + .where(Approval.id == target.id, Approval.status == "pending") + .values(status="answered", # 已回复,但还没提交给引擎 + response=full if isinstance(full, dict) else {"value": full}, + resolved_at=now, **({"resolved_by": actor} if actor else {})) + ) + if marked.rowcount != 1: + await session.rollback() + raise ValueError("这条审批已经处理过了,或者这次运行已经放弃了。刷新看看最新状态") await session.commit() # 凑齐没有?引擎还在等的每一个 interrupt 都得有答案 @@ -299,7 +314,7 @@ class RunManager: select(Approval).where( Approval.run_id == run_id, Approval.status.in_(["answered", "pending"]), - ) + ).execution_options(populate_existing=True) ) ).scalars()) by_iid = {a.interrupt_id: a for a in answered if a.interrupt_id} @@ -321,19 +336,26 @@ class RunManager: # 还差人没回。保持 interrupted,把已回复的那条留在 answered 上等齐 await session.commit() run = await session.get(Run, run_id) + if run.status != "interrupted": + # 审批没了是因为运行在这期间被放弃了:不能回一个「已记下」 + raise ValueError(_cannot_resume(run.status)) return run # 齐了:正式落 resolved,然后驱动引擎。谁批的跟着交给节点, # human.resolved 里才写得出签批人 - actors: dict[str, str | None] = {} - for rec in answered: - if rec.status == "answered": - rec.status = "resolved" - actors[rec.node_id] = rec.resolved_by + ready = [rec for rec in answered if rec.status == "answered"] + actors: dict[str, str | None] = {rec.node_id: rec.resolved_by for rec in ready} + # 回到 running 也用条件更新占住:从上面读状态到这里隔了好几次 await, + # 放弃运行可能正好在这中间把它收成了 cancelled(已经封存、发过 run.cancelled)。 + # 无条件写 running,一次取消了的运行就又被拉起来跑了 + await _claim(session, run_id, ("interrupted",), _cannot_resume) + await session.execute( + update(Approval) + .where(Approval.id.in_([rec.id for rec in ready]), Approval.status == "answered") + .values(status="resolved") + ) run = await session.get(Run, run_id) bus.set_seq(run_id, run.last_seq) - run.status = "running" - run.error = None await session.commit() await session.refresh(run) @@ -356,7 +378,7 @@ class RunManager: )) self._tasks[run_id] = asyncio.create_task( self._drive(run_id, spec, command, workflow_id=workflow_id, **carried, - resumed=[str(n) for n in (getattr(snapshot, "next", None) or ())], + resumed=sorted(_replayed(snapshot)), actors=actors) ) return run @@ -389,6 +411,7 @@ class RunManager: )).first() is not None if not (run.status == "failed" or (run.status == "interrupted" and not has_pending)): raise ValueError(_cannot_continue(run.status, has_pending)) + observed = run.status spec = GraphSpec.model_validate(run.graph) workflow_id = run.workflow_id carried = _carried(run) @@ -419,17 +442,22 @@ class RunManager: "这次运行没有留下可以接着跑的断点(多半是还没跑到第一个节点就挂了)。" "请重新发起一次运行——继续下去只会从头跑一遍,还看不出来。" ) + if stale := await _stale_replay(run_id, spec, snapshot): + raise ValueError( + f"这次运行是升级前停在「{stale}」上的,引擎记录断点的方式已经变了:现在接着跑," + f"「{stale}」里已经执行过的工具会再执行一次。请重新发起一次运行" + ) done = [n for n in (snapshot.values or {}).get("nodes", {}) if n not in pending] async with SessionLocal() as session: + # 和 resume 一样用条件更新占住:读状态之后,放弃运行、或者另一次接着跑 + # 可能已经先动了它 + await _claim(session, run_id, (observed,), + lambda status: _cannot_continue(status, False), + **({"graph": spec.model_dump(mode="json")} if graph is not None else {}), + **({"started_by": actor} if actor else {})) run = await session.get(Run, run_id) bus.set_seq(run_id, run.last_seq) - run.status = "running" - run.error = None - if graph is not None: - run.graph = spec.model_dump(mode="json") - if actor: - run.started_by = actor await session.commit() await session.refresh(run) @@ -450,12 +478,65 @@ class RunManager: ) return run - async def cancel(self, run_id: str) -> bool: + async def cancel(self, run_id: str, *, actor: str | None = None) -> str | None: + """停止一次运行。返回 "stopping"(执行任务已取消,终态由它自己收尾)、 + "cancelled"(停在断点上的,这里直接收成终态),或者 None(已经是终态、或不存在)。 + + 停在断点上的运行——等审批的、服务重启挂起的——手上没有执行任务。以前这里只会 + 去取消任务,于是它们一律「不在执行中」:待审批的那张卡只能批了它、让它跑完。 + """ + if run_id in self._finalizing: + # 执行任务已经跑完、正在落状态——刚发出 run.interrupted、还在写 interrupted + # 的那一刻,界面上已经给出了「放弃这次运行」。这时取消任务会把收尾从中间 + # 打断:状态停在 running、审批还挂着、任务却没了,再点一次只剩 409。 + # 等它落完,再按落下的状态处理 + await self.wait_idle(run_id, timeout=30.0) + if run_id in self._finalizing: # 收尾卡住了(库被锁着):也不去打断它 + return None task = self._tasks.get(run_id) if task and not task.done(): + self._stopped_by[run_id] = actor task.cancel() - return True - return False + return "stopping" + return "cancelled" if await self._abandon(run_id, actor) else None + + async def _abandon(self, run_id: str, actor: str | None) -> bool: + """把停在断点上的运行收成 cancelled:关掉待审批、发 run.cancelled、封存。""" + now = datetime.now(timezone.utc) + async with SessionLocal() as session: + # 条件更新占住这次收尾:同一时刻有人批了它(resume 把它改回 running), + # 或者另一个取消先到了,这里就什么都不做 + claimed = await session.execute( + update(Run).where(Run.id == run_id, Run.status == "interrupted") + .values(status="cancelled", finished_at=now) + ) + if claimed.rowcount != 1: + await session.rollback() + return False + closed = await _close_approvals(session, run_id, actor, now) + run = await session.get(Run, run_id) + timing = await _timing(session, run, 0, now) + run.usage = {**(run.usage or {}), "duration_ms": timing["active_ms"], **timing} + run.error = "用户取消:在等审批时放弃了这次运行" if closed else "用户取消:挂起期间放弃了这次运行" + bus.set_seq(run_id, run.last_seq or 0) + # 和状态同一个事务提交,理由同 _finalize + event = self._event(run_id, EventType.RUN_CANCELLED, data={ + "timing": timing, "actor": actor, + "message": f"放弃了这次运行,{closed} 条待审批一并关闭" if closed + else "放弃了这次挂起的运行", + }) + await _stage(session, event, run) + await session.commit() + + await bus.publish(event) + await self._seal(run_id) + # 等审批时在线的连接没有关(它还在等恢复),这里让它收到终态再结束 + await bus.close(run_id) + with contextlib.suppress(Exception): + from app.sandbox.manager import sandbox_manager + + await sandbox_manager.cleanup(run_id) + return True def is_active(self, run_id: str) -> bool: task = self._tasks.get(run_id) @@ -512,7 +593,9 @@ class RunManager: # 实际生效的运行参数进事件流,也就进了封存清单: # 正式运行查的是哪个库、审批默认是什么,事后可复核 "memory_scope": memory_scope, "collection": collection, - "approval_default": approval_default}, + "approval_default": approval_default, + # 这一段是按哪一版重放协议跑的,恢复时据此认出升级前做下的工作 + "replay_protocol": PROTOCOL}, ) run_ctx = RunContext( @@ -531,6 +614,8 @@ class RunManager: config = { "configurable": {"thread_id": run_id}, "recursion_limit": settings.max_graph_steps + extra_steps, + # 进每个 checkpoint 的 metadata:恢复时据此认出旧版引擎停下的断点 + "metadata": {PROTOCOL_KEY: PROTOCOL}, } interrupted = False @@ -569,7 +654,7 @@ class RunManager: else: status = "cancelled" error = "用户取消" - terminal = (EventType.RUN_CANCELLED, {}) + terminal = (EventType.RUN_CANCELLED, {"actor": self._stopped_by.pop(run_id, None)}) raise except TimeoutError as e: status = "failed" @@ -611,6 +696,7 @@ class RunManager: # gather 在别的事件循环里炸开 self._finalizing.discard(run_id) self._tasks.pop(run_id, None) + self._stopped_by.pop(run_id, None) # 运行到终态就把沙箱会话收掉。thread_id 每个 run 都是新的, # 不收的话每跑一次带代码节点的图就多一台常驻 microVM(几百 MB), # 而且没有任何自动回收路径会碰它——闲置回收只在"下次有人执行 @@ -692,13 +778,41 @@ class RunManager: now = datetime.now(timezone.utc) timing = {"wall_ms": elapsed_ms, "active_ms": elapsed_ms, "wait_ms": 0} + # 终态事件和运行记录在同一个事务里提交,提交之后再广播。两边都有人在读: + # 读到 failed 的一方马上去翻事件,要翻得到 run.failed(报错原因和定位都在 + # 那条事件里);收到 run.finished 的一方马上 GET 这次运行取完整成果(事件里 + # 的 output 是截断的),要读得到 succeeded 和 output。分两次提交,总有一边 + # 会读到半截——以前先提交状态,前一种偶尔翻不到;反过来先落事件,后一种 + # 每次都读到 running 和空的 output + event: RunEventModel | None = None async with SessionLocal() as session: run = await session.get(Run, run_id) if run: timing = await _timing(session, run, elapsed_ms, now) + if status != "succeeded": + # 状态里的用量只算跑完了的节点。失败、取消、停在审批上的那个节点已经 + # 花出去的调用(校验修复、agent 的前几步)只在 llm.end 里——以前这部分 + # 在运行记录上凭空消失,失败的运行看着像没花钱 + usage = _spent(usage, await _usage_of_events(session, run_id)) # duration_ms 是各段执行时长之和(= active_ms)。以前用这一段的耗时 # 覆盖它:87 秒、中间等过一次审批的运行,列表上写着 22ms usage.update(duration_ms=timing["active_ms"], **timing) + + if status == "succeeded": + # 事件里的 output 仍然截断(事件体积要控制),但截了就要说:消费方据此 + # 回源 GET /api/runs/{id} 取全文。以前截得悄无声息,问数据页把 2000 字处 + # 切断的半截答案当成完整结论落了库 + clipped, truncated = _clip(output) + data: dict[str, Any] = {"output": clipped, "usage": usage, + "duration_ms": timing["active_ms"], "timing": timing} + if truncated: + data["output_truncated"] = True + event = self._event(run_id, EventType.RUN_FINISHED, data=data) + elif terminal is not None: + kind, data = terminal + event = self._event(run_id, kind, data={**data, "timing": timing}) + + if run: run.status = status run.error = error run.output = output @@ -706,21 +820,17 @@ class RunManager: run.error_node_id = error_node if status != "interrupted": run.finished_at = now + if status in ("failed", "cancelled"): + # 并行支路上已经停下来等人的那张审批卡,运行结束了它也就没有意义了; + # 留着的话它一直挂在全局待办里,批了也只会被告知运行不在断点上 + actor = terminal[1].get("actor") if terminal else None + await _close_approvals(session, run_id, actor, now) + if event is not None: + await _stage(session, event, run) await session.commit() + if event is not None: + await bus.publish(event) - if status == "succeeded": - # 事件里的 output 仍然截断(事件体积要控制),但截了就要说:消费方据此 - # 回源 GET /api/runs/{id} 取全文。以前截得悄无声息,问数据页把 2000 字处 - # 切断的半截答案当成完整结论落了库 - clipped, truncated = _clip(output) - data: dict[str, Any] = {"output": clipped, "usage": usage, - "duration_ms": timing["active_ms"], "timing": timing} - if truncated: - data["output_truncated"] = True - await self._emit(run_id, EventType.RUN_FINISHED, data=data) - elif terminal is not None: - kind, data = terminal - await self._emit(run_id, kind, data={**data, "timing": timing}) if status != "interrupted": # 终态事件落库之后再封存。以前在 run.finished 之前算,清单里恰恰少了 # 那条宣布"跑完了、成果是什么"的事件——改它的成果,清单照样对得上。 @@ -900,6 +1010,128 @@ async def _approval_default(*, run_class: str) -> str: return tool_approval_default(await run_defaults(session)) +def _replayed(snapshot: Any) -> set[str]: + """从断点驱动时要(重新)执行的节点:还没跑的(next),加上停在 interrupt 上、或者 + 失败了的。恢复过一次的节点手里已经有答复的写入,不在 next 里,只能从 tasks 上认。""" + again = {str(x) for x in getattr(snapshot, "next", None) or ()} + again.update(str(t.name) for t in getattr(snapshot, "tasks", None) or () + if getattr(t, "interrupts", None) or getattr(t, "error", None)) + return again + + +async def _stale_replay(run_id: str, spec: GraphSpec, snapshot: Any) -> str | None: + """旧版重放协议写下的断点,按现在的代码重放会不会把做过的工具再做一遍。会的话返回节点名。 + + 有风险的是:断点之后,旧版代码在这个节点里已经执行过工具、或者已经批过一次。它们 + 的 task 缓存排在旧的位置上,新代码多插了记录 task,位置全错开了。停在第一次审批上、 + 之前什么都没做的,重放只会多发一条 human.requested,照常放行;人工审批节点同理。 + + 「断点之后」按 checkpoint 的写入时刻算:同一个 superstep 里恢复不会写新的 checkpoint, + 所以升级后恢复过一次的运行,断点仍是旧版写的,而那之后的工作是新版做的—— + 哪一段是哪一版,看那一段 run.started 里记的 replay_protocol。 + """ + if protocol_of(getattr(snapshot, "metadata", None)) >= PROTOCOL: + return None + nodes = spec.node_map() + suspects = [n for n in _replayed(snapshot) if n in nodes and nodes[n].type != NodeType.HUMAN] + if not suspects: + return None + try: + since = datetime.fromisoformat(str(snapshot.created_at)).timestamp() + except (AttributeError, TypeError, ValueError): + since = 0.0 + async with SessionLocal() as session: + rows = (await session.execute( + select(RunEvent.type, RunEvent.node_id, RunEvent.ts, RunEvent.data) + .where(RunEvent.run_id == run_id, + RunEvent.type.in_([EventType.RUN_STARTED, EventType.TOOL_START, + EventType.HUMAN_RESOLVED])) + .order_by(RunEvent.seq) + )).all() + protocol = 1 + for kind, owner, ts, data in rows: + if kind == EventType.RUN_STARTED: + protocol = protocol_of({PROTOCOL_KEY: (data or {}).get("replay_protocol")}) + continue + if protocol >= PROTOCOL or (ts or 0) < since: + continue + for node_id in suspects: + if owner == node_id or (owner or "").startswith(f"{node_id}/"): + return nodes[node_id].title or node_id + return None + + +async def _claim( + session: Any, run_id: str, expected: tuple[str, ...], refusal: Callable[[str], str], + **values: Any, +) -> None: + """只在状态仍是 expected 时把运行改成 running;已经被别人改过就回滚、说明原因。""" + claimed = await session.execute( + update(Run).where(Run.id == run_id, Run.status.in_(expected)) + .values(status="running", error=None, **values) + ) + if claimed.rowcount != 1: + await session.rollback() + status = await session.scalar(select(Run.status).where(Run.id == run_id)) + raise ValueError(refusal(status or "")) + + +async def _stage(session: Any, event: RunEventModel, run: Run | None = None) -> None: + """把一条事件写进这个 session,随它一起提交。run 已经在 session 里时直接改它的 last_seq。""" + session.add(RunEvent(run_id=event.run_id, seq=event.seq, type=str(event.type), + node_id=event.node_id, ts=event.ts, data=event.data)) + if run is not None: + run.last_seq = event.seq + else: + await session.execute( + update(Run).where(Run.id == event.run_id).values(last_seq=event.seq) + ) + + +async def _close_approvals(session: Any, run_id: str, actor: str | None, now: datetime) -> int: + """运行已经结束,它还没处理完的审批一并关掉。返回关掉了几条。""" + rows = list((await session.execute( + select(Approval).where(Approval.run_id == run_id, + Approval.status.in_(["pending", "answered"])) + )).scalars()) + for approval in rows: + approval.status = "cancelled" + approval.resolved_at = now + approval.resolved_by = actor + return len(rows) + + +_USAGE_KEYS = ("input_tokens", "output_tokens", "cost_usd", "calls") + + +async def _usage_of_events(session: Any, run_id: str) -> dict[str, Any]: + """按 llm.end 事件逐次加总的用量:每次模型调用都有一条,不管节点最后成没成。""" + rows = await session.execute( + select(RunEvent.data).where(RunEvent.run_id == run_id, RunEvent.type == EventType.LLM_END) + ) + total: dict[str, Any] = dict.fromkeys(_USAGE_KEYS, 0) + for (data,) in rows: + for key in _USAGE_KEYS: + value = (data or {}).get(key, 1 if key == "calls" else 0) + if isinstance(value, (int, float)) and not isinstance(value, bool): + total[key] += value + total["cost_usd"] = round(total["cost_usd"], 6) + return total + + +def _spent(state_usage: dict[str, Any], events_usage: dict[str, Any]) -> dict[str, Any]: + """两本账取大的那本。新引擎每次调用都发 llm.end,事件那本不会更少; + 加这条事件之前的老运行(agent 节点不发 llm.end)状态那本更全。""" + out = dict(state_usage) + for key in _USAGE_KEYS: + mine, theirs = out.get(key) or 0, events_usage.get(key) or 0 + if isinstance(mine, (int, float)) and theirs > mine: + out[key] = theirs + if "input_tokens" in out or "output_tokens" in out: + out["total_tokens"] = int(out.get("input_tokens") or 0) + int(out.get("output_tokens") or 0) + return out + + async def _timing(session: Any, run: Run, elapsed_ms: int, now: datetime) -> dict[str, int]: """wall:从第一次开始到现在的墙钟;active:各段执行时长之和;wait:等人审批的总时长。 diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 686ced9a..70693d51 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -150,6 +150,9 @@ class ValidationIssue(BaseModel): node_id: str | None = None edge_id: str | None = None message: str + #: 节点配置里出问题的那个字段:prompt、cases[1].condition、agents[0].system。 + #: 检查器靠它把问题落到具体的输入框下面;说不清是哪个字段的(连线、图级)为空 + field: str | None = None class ValidationResult(BaseModel): @@ -157,9 +160,10 @@ class ValidationResult(BaseModel): issues: list[ValidationIssue] = Field(default_factory=list) def add(self, message: str, *, level: str = "error", node_id: str | None = None, - edge_id: str | None = None) -> None: + edge_id: str | None = None, field: str | None = None) -> None: self.issues.append( - ValidationIssue(level=level, message=message, node_id=node_id, edge_id=edge_id) # type: ignore[arg-type] + ValidationIssue(level=level, message=message, node_id=node_id, # type: ignore[arg-type] + edge_id=edge_id, field=field) ) if level == "error": self.ok = False @@ -294,32 +298,33 @@ def innermost_loops(spec: GraphSpec) -> dict[str, str]: return out -def expression_fields(node: GraphNode) -> list[tuple[str, str]]: - """这个节点上按表达式求值的字段:(给人看的名字, 原文)。 +def expression_fields(node: GraphNode) -> list[tuple[str, str, str]]: + """这个节点上按表达式求值的字段:(给人看的名字, 原文, 字段路径)。 跟着模式走:foreach 循环的 condition、模板模式整形的 expression 不参与求值, 拿它们报错就是误报。 """ cfg = node.config - out: list[tuple[str, str]] = [] + out: list[tuple[str, str, str]] = [] - def add(label: str, value: Any) -> None: + def add(label: str, value: Any, field: str) -> None: if isinstance(value, str) and value.strip(): - out.append((label, value)) + out.append((label, value, field)) - add("跳过条件", cfg.get("skip_if")) + add("跳过条件", cfg.get("skip_if"), "skip_if") if node.type == NodeType.BRANCH and cfg.get("mode", "expression") == "expression": - for case in cfg.get("cases") or []: + for i, case in enumerate(cfg.get("cases") or []): case = case or {} - add(f"分支「{case.get('label') or case.get('key') or '?'}」的条件", case.get("condition")) + add(f"分支「{case.get('label') or case.get('key') or '?'}」的条件", case.get("condition"), + f"cases[{i}].condition") elif node.type == NodeType.LOOP and cfg.get("mode", "foreach") == "while": - add("循环条件", cfg.get("condition")) + add("循环条件", cfg.get("condition"), "condition") elif node.type == NodeType.TRANSFORM and cfg.get("mode", "expression") == "expression": - add("整形表达式", cfg.get("expression")) + add("整形表达式", cfg.get("expression"), "expression") elif node.type == NodeType.METRICS: - for d in cfg.get("metrics") or []: + for i, d in enumerate(cfg.get("metrics") or []): d = d or {} - add(f"指标「{d.get('id') or '?'}」的表达式", d.get("expression")) + add(f"指标「{d.get('id') or '?'}」的表达式", d.get("expression"), f"metrics[{i}].expression") return out @@ -345,19 +350,20 @@ def _check_case_keys(node: GraphNode, cases: list[Any], result: ValidationResult case = case or {} key = str(case.get("key") or "").strip() name = case.get("label") or key or f"第 {i} 个" + field = f"cases[{i - 1}].key" if not key: result.add(f"「条件分支」的分支「{name}」没有标识(key),连不出边、也走不到它", - node_id=node.id) + node_id=node.id, field=field) continue if key == "default": result.add( f"分支「{name}」的标识用了 default:这是「其他」兜底出口的保留名,两者会合并成" "同一个出口,跑完分不清走的是哪条。换一个标识,比如 team", - level="warning", node_id=node.id, + level="warning", node_id=node.id, field=field, ) elif key in seen: result.add(f"「条件分支」里有两个分支的标识都是 {key!r}:路由只认标识," - "后一个永远轮不到。给它换一个不重复的标识", node_id=node.id) + "后一个永远轮不到。给它换一个不重复的标识", node_id=node.id, field=field) seen.add(key) @@ -386,43 +392,46 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: if node.type == NodeType.BRANCH: cases = cfg.get("cases") or [] if not cases: - result.add("「条件分支」至少要配一个分支条件", node_id=node.id) + result.add("「条件分支」至少要配一个分支条件", node_id=node.id, field="cases") handles = {e.sourceHandle for e in spec.outgoing(node.id)} _check_case_keys(node, cases, result) - for case in cases: + for i, case in enumerate(cases): key = str((case or {}).get("key") or "").strip() if key and key != "default" and key not in handles: result.add( - f"分支 {key!r} 没有连出去的边", level="warning", node_id=node.id + f"分支 {key!r} 没有连出去的边", level="warning", node_id=node.id, + field=f"cases[{i}].key", ) if "default" not in handles: result.add( "建议给「条件分支」的「其他」出口连一条边,兜住所有条件都不满足的情况", level="warning", node_id=node.id, + field="cases", ) elif node.type == NodeType.TOOL: if not cfg.get("tool"): - result.add("「调用工具」节点还没选工具", node_id=node.id) + result.add("「调用工具」节点还没选工具", node_id=node.id, field="tool") elif node.type == NodeType.SUBGRAPH: if not cfg.get("workflow_id"): - result.add("「子工作流」节点还没选要嵌套的工作流", node_id=node.id) + result.add("「子工作流」节点还没选要嵌套的工作流", node_id=node.id, + field="workflow_id") elif node.type == NodeType.VALIDATE: if not cfg.get("schema"): - result.add("「结构校验」节点需要一个 JSON Schema", node_id=node.id) + result.add("「结构校验」节点需要一个 JSON Schema", node_id=node.id, field="schema") elif node.type == NodeType.METRICS: defs = cfg.get("metrics") or [] if not defs: - result.add("口径卡还没有定义指标", node_id=node.id) - for d in defs: + result.add("口径卡还没有定义指标", node_id=node.id, field="metrics") + for i, d in enumerate(defs): if not d.get("id") or not d.get("expression"): result.add(f"指标定义缺 id 或 expression:{d.get('id') or '(空)'}", - node_id=node.id) + node_id=node.id, field=f"metrics[{i}]") elif node.type == NodeType.OUTPUT: contract = cfg.get("contract") if contract and not contract.get("metrics_from"): result.add("出具契约缺 metrics_from(指标来自哪个「口径卡」节点)", - node_id=node.id) + node_id=node.id, field="contract.metrics_from") elif node.type == NodeType.LOOP: if not spec.outgoing(node.id): result.add("「循环」节点没有循环体", node_id=node.id) @@ -433,6 +442,7 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: "没有指定模型,将回退到默认 provider", level="warning", node_id=node.id, + field="model", ) # 表达式:用运行时同一个 parse_expression 先解析一遍。以前写错了只有跑到那一步 @@ -441,30 +451,32 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: from app.engine.expressions import ExpressionError, parse_expression for node in spec.nodes: - for label, text in expression_fields(node): + for label, text, field in expression_fields(node): try: _, unwrapped, unknown = parse_expression(text) except ExpressionError as e: - result.add(f"{label}写错了:{e}(原文:{text})", node_id=node.id) + result.add(f"{label}写错了:{e}(原文:{text})", node_id=node.id, field=field) continue if unwrapped: result.add( f"{label}里的 {{{{ }}}} 是多余的——这里是表达式、不是模板," f"已按 {'、'.join(unwrapped)} 理解", level="warning", node_id=node.id, + field=field, ) if unknown: result.add( f"{label}里的 {'、'.join(unknown)} 不是能用的名字,运行时会取到空值" "(变量要写全:vars.x、input.x、nodes.某节点.text)", - level="warning", node_id=node.id, + level="warning", node_id=node.id, field=field, ) cfg = node.config if node.type == NodeType.LOOP and cfg.get("mode", "foreach") == "while" \ and not str(cfg.get("condition") or "").strip(): - result.add("while 循环没有写条件,循环体一次都不会跑", level="warning", node_id=node.id) + result.add("while 循环没有写条件,循环体一次都不会跑", level="warning", node_id=node.id, + field="condition") if node.type == NodeType.TRANSFORM and cfg.get("mode", "expression") == "expression" \ and not str(cfg.get("expression") or "").strip(): - result.add("整形节点没有填表达式", node_id=node.id) + result.add("整形节点没有填表达式", node_id=node.id, field="expression") # assign_to 拿到的是 stdout。脚本最后一行写个裸表达式不会输出(那是 notebook # 的行为)——变量是空的,下游的循环条件一上来就不成立、成果也是空的,而整次 # 运行照样"成功"。开发库里的「测试 1」就是这样:修好条件之后跑通了,结果为空 @@ -478,9 +490,11 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: result.add( f"代码把输出交给 vars.{var},可代码里没有 print:Python 脚本最后一行的表达式" f"不会自动输出,vars.{var} 会是空的。把结果 print 出来;要交给下游一个对象," - "就 print(json.dumps(结果))", node_id=node.id, + "就 print(json.dumps(结果))", node_id=node.id, field="code", ) + _check_named_tools(spec, result) + # 孤儿节点 for node in spec.nodes: if node.type in (NodeType.INPUT, NodeType.OUTPUT): @@ -497,6 +511,204 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: # info 级是"产出没人用"这类提示,不进校验(它不影响能不能跑) if issue.level == "info": continue - result.add(issue.message, level=issue.level, node_id=issue.node_id) + result.add(issue.message, level=issue.level, node_id=issue.node_id, + field=issue.field or None) return result + + +# -------------------------------------------------------------------------- +# 提示词点名的工具,节点得真的绑了它 +# +# 一次真实运行:助手改图时把三个查库 agent 的 tools 丢了,提示词里还写着「用 +# db_query__… 查」。模型拿不到工具,只能写一句「假设调用工具」,再往下编;校验节点 +# 的修复又替它编了个 0。这张图在跑之前就能认出来。Copilot 的自查读的是同一份 +# error,搭图时就会交回模型去修。 +# +# 这条是 error,会挡住运行,还会作为 Copilot 自查的阻断项交回模型(模型可能反过来 +# 把提示词里禁用的工具绑上去),所以宁可漏判不可误判: +# - 认得出的工具名才算点名:内置工具、db_query__ / db_schema__ / mcp: 这几种形状, +# 以及这张图里别的节点绑过的名字; +# - 「不要用 X」「Never call X」「X 的结果」不是在要求用它; +# - calculator 这种本身就是个普通词的工具名,只有写在反引号里、或者紧跟在「用 / +# 调用 / use / call」后面才算点名;「可以用」「如有需要」「if needed」这类非强制的 +# 说法也一样。这两种只报 warning。 +# -------------------------------------------------------------------------- + +_DYNAMIC_TOOL = r"db_(?:query|schema)__[A-Za-z0-9_\-]+|mcp:[\w\-]+/[\w\-.]+" +# 工具名紧挨着汉字是常态(「用python_exec核对」),所以边界只排 ASCII 标识符字符 +_EDGE_BEFORE, _EDGE_AFTER = r"(? set[str]: + return {t for t in cfg.get("tools") or [] if isinstance(t, str)} + + +def _covers(bound: set[str], tool: str) -> bool: + """tool 在不在 bound 里。mcp:/*(或只写 mcp:)绑的是这个 server 的全部工具。""" + if tool in bound: + return True + if not tool.startswith("mcp:"): + return False + server = tool[4:].partition("/")[0] + return any(b in (f"mcp:{server}", f"mcp:{server}/", f"mcp:{server}/*") for b in bound) + + +def _named_tool_pattern(spec: GraphSpec) -> re.Pattern[str]: + from app.tools.registry import all_specs + + bound: set[str] = set() + for node in spec.nodes: + cfg = node.config + bound.update(_bound_tools(cfg)) + if isinstance(cfg.get("tool"), str): + bound.add(cfg["tool"]) + for member in cfg.get("agents") or []: + if isinstance(member, dict): + bound.update(_bound_tools(member)) + names = sorted({*all_specs(), *bound}, key=len, reverse=True) + literal = "|".join(re.escape(n) for n in names if n) + body = f"{_DYNAMIC_TOOL}|{literal}" if literal else _DYNAMIC_TOOL + return re.compile(f"{_EDGE_BEFORE}(?:{body}){_EDGE_AFTER}") + + +# 列举里的分隔:「严禁调用 web_search、web_fetch、http_request 等」「never call a, b or c」 +_LIST_TAIL = re.compile(r"(?:[\s、,,/和或及与跟以「」“”\"'`]|(? bool: + """这个工具名前面是不是一句「不要用」。列举里排在后面的也算:往回跳过前面的工具名和顿号。""" + head = plain[max(0, start - 120):start] + while True: + trimmed = _LIST_TAIL.sub("", head) + last = None + for last in pattern.finditer(trimmed): + pass + if last is not None and last.end() == len(trimmed): + trimmed = trimmed[:last.start()] + if trimmed == head: + break + head = trimmed + return bool(_NEGATED.search(head[-40:] + " ")) + + +def _clause(plain: str, start: int, end: int) -> str: + """工具名所在的那一句。""" + before = [m.end() for m in _CLAUSE_END.finditer(plain, 0, start)] + after = _CLAUSE_END.search(plain, end) + return plain[before[-1] if before else 0:after.start() if after else len(plain)] + + +def _asked_tools(text: str, pattern: re.Pattern[str]) -> dict[str, bool]: + """文本里被要求使用的工具名,按出现顺序。值是「是不是明确要求」:否的只算提到了。""" + # {{ }} 里的是模板引用,不是在点名工具;换成等长空白,位置不变 + plain = _TEMPLATE_SPAN.sub(lambda m: " " * len(m.group(0)), text) + found: dict[str, bool] = {} + for m in pattern.finditer(plain): + if _negated(plain, m.start(), pattern) or _REFERENCED.match(plain[m.end():m.end() + 12]): + continue + name = m.group(0) + explicit = not _OPTIONAL.search(_clause(plain, m.start(), m.end())) + if explicit and _PLAIN_WORD.fullmatch(name): + quoted = plain[m.start() - 1:m.start()] == "`" and plain[m.end():m.end() + 1] == "`" + explicit = quoted or bool(_USE_VERB.search(plain[max(0, m.start() - 12):m.start()])) + found[name] = found.get(name, False) or explicit + return found + + +def _asks_vaguely(text: str) -> bool: + plain = _TEMPLATE_SPAN.sub(" ", text) + return any(not _NEGATED.search(plain[max(0, m.start() - 12):m.start()]) + for m in _VAGUE.finditer(plain)) + + +def _check_named_tools(spec: GraphSpec, result: ValidationResult) -> None: + agents = [n for n in spec.nodes if n.type in (NodeType.AGENT, NodeType.SUPERVISOR)] + if not agents: + return + pattern = _named_tool_pattern(spec) + + def check(node: GraphNode, texts: list[tuple[str, Any]], tools: Any, who: str = "") -> None: + bound = {t for t in tools or [] if isinstance(t, str)} + vague_at: str | None = None + reported: set[str] = set() + asked: dict[str, tuple[bool, str]] = {} + for field, text in texts: + if not isinstance(text, str) or not text.strip(): + continue + for tool, explicit in _asked_tools(text, pattern).items(): + # system 和 prompt 里都点了同一个工具,报一次就够:指到先出现的那处, + # 哪一处是明确要求就按要求算 + seen = asked.get(tool) + asked[tool] = (explicit or bool(seen and seen[0]), seen[1] if seen else field) + if not bound and vague_at is None and _asks_vaguely(text): + vague_at = field + for tool, (explicit, field) in asked.items(): + if _covers(bound, tool): + continue + reported.add(tool) + if explicit: + message = (f"{who}的提示词要求用「{tool}」,但没有给这个成员绑定它" if who + else f"提示词要求用「{tool}」,但节点没有绑定它") + result.add(f"{message}:运行时模型拿不到这个工具,只能编一个结果出来。" + "把它加进工具里,或者改掉提示词里的要求", + node_id=node.id, field=field) + else: + result.add(f"{who + '的' if who else ''}提示词提到了「{tool}」,但{'这个成员' if who else '节点'}没有" + "绑定它:如果是要模型调用它,运行时拿不到。需要就把它加进工具里", + level="warning", node_id=node.id, field=field) + # 已经点名报过了,那句笼统的就不再重复 + if vague_at is not None and not reported: + result.add(f"{who or '提示词'}要求调用工具,但{'这个成员' if who else '这个节点'}没有绑定任何" + "工具:模型拿不到工具,只能假设一个结果。绑定要用的工具,或者改掉这句要求", + level="warning", node_id=node.id, field=vague_at) + + for node in agents: + cfg = node.config + if node.type == NodeType.AGENT: + check(node, [("system", cfg.get("system")), ("prompt", cfg.get("prompt"))], + cfg.get("tools")) + continue + members = [m for m in cfg.get("agents") or [] if isinstance(m, dict)] + team_tools: set[str] = set() + for i, member in enumerate(members): + name = member.get("name") or f"agent{i}" + team_tools.update(_bound_tools(member)) + check(node, [(f"agents[{i}].system", member.get("system"))], member.get("tools"), + who=f"成员「{name}」") + # 协作目标是给调度者看的:点了名的工具,至少得有一个成员拿得到 + goal = cfg.get("goal") + if isinstance(goal, str): + for tool, explicit in _asked_tools(goal, pattern).items(): + if _covers(team_tools, tool): + continue + if explicit: + result.add(f"协作目标要求用「{tool}」,但没有哪个成员绑定了它:调度者派出去的人" + "谁都调不了它。给负责的成员加上这个工具,或者改掉目标里的要求", + node_id=node.id, field="goal") + else: + result.add(f"协作目标提到了「{tool}」,但没有哪个成员绑定了它:如果是要成员" + "调用它,谁都拿不到。需要就给负责的成员加上", + level="warning", node_id=node.id, field="goal") diff --git a/backend/app/engine/toolcalls.py b/backend/app/engine/toolcalls.py new file mode 100644 index 00000000..52c096c5 --- /dev/null +++ b/backend/app/engine/toolcalls.py @@ -0,0 +1,175 @@ +"""节点里调工具的公共部分:时限,以及「模型把工具调用写成了文字」的识别。 + +agent 节点、协作成员、工具节点三处都在调工具。以前时限只有数据层自己那一道 +wait_for,而 wait_for 取消之后要等被取消的那一方收完尾:驱动关连接、回滚要排在 +数据库那条语句后面。真实运行里一次「查询超过 30s 被中断」实际等了 90 秒——时限 +到了、取消也发了,控制权却迟迟回不来,报错写的还是 30。 +""" +from __future__ import annotations + +import asyncio +import re +from dataclasses import dataclass +from typing import Any, Awaitable + +QUERY_PREFIX = "db_query__" + +#: 到了工具自己的时限之后再宽限多久才放弃。工具自己的超时报错(「查询超过 30s +#: 被中断,加上 WHERE…」)比这里的说法更具体,先让它到;收尾卡住了才由这里兜底 +GRACE_S = 1.0 + +#: 沙箱代码工具的时限来自参数(缺省 30 秒),由沙箱自己执行 +_SANDBOX_TOOLS = {"python_exec", "shell_exec", "run_code"} +_HTTP_TOOLS = {"http_request", "web_fetch"} + + +@dataclass(frozen=True) +class Limit: + """一次工具调用的时限。seconds 随 tool.start 发给界面,界面据此说「已超出 30s 上限」。""" + + seconds: float + #: 到点由引擎收回控制权。只对「时限到了、收尾却可能卡住」的类型开:沙箱的时限 + #: 里还含着起虚拟机、装依赖,按它掐会误杀冷启动;HTTP 客户端自己掐得准 + enforce: bool + #: query | declared | sandbox | http + kind: str + + @property + def self_timed(self) -> bool: + """工具自己也按这个时限掐、并且自己收尾(数据层的查询)。引擎放弃它时只撒手、 + 不再取消:它那时多半正在收尾,再取消一次会把关连接那一步打断,连接就再也回不到池里。""" + return self.kind == "query" + + +def limit_of(tool: Any, name: str, args: dict[str, Any]) -> Limit | None: + """这次调用的时限。说不出来就是 None——界面上不写上限,引擎也不掐。 + + 工具自己在 metadata["timeout_s"] 里声明的优先(数字,或者按参数算的函数)。 + """ + meta = (getattr(tool, "metadata", None) or {}) if tool is not None else {} + declared = meta.get("timeout_s") + if callable(declared): + declared = declared(args) + if not (isinstance(declared, (int, float)) and not isinstance(declared, bool) and declared > 0): + declared = None + if name.startswith(QUERY_PREFIX): + # 数据层自己按这个时限掐、自己收尾。数据源声明了自己的时限就用它, + # 否则按模块属性现取数据层的缺省——那个数只有这一处定义 + from app.data import guard + + return Limit(float(declared or guard.QueryLimits().timeout_seconds), True, "query") + if declared: + return Limit(float(declared), True, "declared") + if name in _SANDBOX_TOOLS: + try: + seconds = float(args.get("timeout") or 30) + except (TypeError, ValueError): + seconds = 30.0 + return Limit(seconds, False, "sandbox") + if name in _HTTP_TOOLS: + from app.core.config import settings + + return Limit(float(settings.http_tool_timeout), False, "http") + return None + + +def limit_fields(limit: Limit | None) -> dict[str, Any]: + """tool.start 上附带的时限字段。没有时限就什么都不带,老客户端照旧。""" + if limit is None: + return {} + return {"timeout_s": int(limit.seconds) if limit.seconds.is_integer() else limit.seconds} + + +def _seconds(value: float) -> str: + return str(int(value)) if float(value).is_integer() else f"{value:g}" + + +class ToolTimeout(TimeoutError): + """引擎等到时限就放弃了。消息直接给模型和人看:等了多久、为什么放弃、怎么办。""" + + def __init__(self, name: str, limit: Limit) -> None: + self.name = name + self.limit = limit + seconds = _seconds(limit.seconds) + if limit.kind == "query": + text = (f"查询超过 {seconds}s 没有返回,已放弃等待(数据库那边可能还在跑,连接会在后台" + "收回)。加上 WHERE 条件或 LIMIT 缩小范围再查") + else: + text = f"工具 {name} 超过 {seconds}s 没有返回,已放弃等待" + super().__init__(text) + + +async def run_bounded(call: Awaitable[Any], limit: Limit | None, name: str) -> Any: + """跑一次工具调用;到了时限(加一点宽限)就收回控制权,不等它收尾。 + + 被放弃的那次调用照样被取消,只是它的收尾(关连接、回滚)挪到后台去做完。 + """ + if limit is None or not limit.enforce: + return await call + job = asyncio.ensure_future(call) + try: + done, _ = await asyncio.wait({job}, timeout=limit.seconds + GRACE_S) + except asyncio.CancelledError: + # 整次运行被取消(用户停止、服务关停):底下那次调用也不该接着跑 + _abandon(job) + raise + if job in done: + return job.result() + _abandon(job, cancel=not limit.self_timed) + raise ToolTimeout(name, limit) + + +def _abandon(job: asyncio.Future[Any], *, cancel: bool = True) -> None: + if cancel: + job.cancel() + # 结果没人要了;把异常取走,免得事件循环报 "exception was never retrieved" + job.add_done_callback(lambda f: f.cancelled() or f.exception()) + + +# -------------------------------------------------------------------------- +# 模型把工具调用写成了文字 +# +# 有的模型(或它前面那层网关)不认工具调用接口、或者节点根本没绑工具,模型就把自己 +# 训练时那套调用格式当正文吐出来。这一步没有任何真实调用,数据一次都没查到,而文字 +# 看上去像是「在查」。认出来之后先纠正一次,仍然如此就判失败。 +# -------------------------------------------------------------------------- + +_MARKUP = re.compile( + r"<[||]{1,2}\s*DSML\s*[||]{1,2}" # DeepSeek 的 DSML + r"|<[||]\s*tool[▁_ ]calls?[▁_ ]begin\s*[||]>" # DeepSeek 的特殊 token + r"|<\s*(?:tool_call|function_call)\s*>" + r"|<\s*invoke\s+name\s*=", + re.IGNORECASE, +) +# 只有一段 {"tool": …, "sql": …} 不算:正文里引用一段 JSON 很正常。 +# 得同时自称是「假设」调的,才是在编 +_TOOL_JSON = re.compile(r'\{\s*"tool"\s*:') +_SQL_KEY = re.compile(r'"sql"\s*:') +_PRETEND = re.compile(r"假设(?:调用|使用|执行)了?(?:工具|查询)|假装调用|模拟调用(?:了)?工具") + +TOOL_MARKUP_ERROR = ( + "模型输出了工具调用的原始标记,但没有真正调用工具,这一步一次都没查到数据。" + "常见原因:节点没有绑定工具,或者模型、服务不支持工具调用。" + "到画布里给这个节点绑定要用的工具;绑定了还这样,就换一个支持工具调用的模型" +) + +TOOL_MARKUP_NUDGE = ( + "你上一条回复把工具调用写成了文字,系统没有执行它,也就没有拿到任何数据。" + "要用工具,请通过工具调用接口发起,不要把调用格式写进正文;手上没有能用的工具," + "就直接说明查不到、缺什么,不要假设调用的结果。" +) + + +def leaked_markup(text: str | None) -> str | None: + """正文里露出来的工具调用标记(截一小段,给警告用)。没有就是 None。""" + if not text: + return None + if m := _MARKUP.search(text): + return m.group(0) + if _TOOL_JSON.search(text) and _SQL_KEY.search(text) and (m := _PRETEND.search(text)): + return m.group(0) + return None + + +def markup_warning(snippet: str, who: str = "模型") -> str: + return f"{who}把工具调用写成了文字({snippet[:40]}…),没有真正调用工具" diff --git a/backend/app/engine/variables.py b/backend/app/engine/variables.py index 7a7d6469..76e2881f 100644 --- a/backend/app/engine/variables.py +++ b/backend/app/engine/variables.py @@ -68,6 +68,8 @@ class VarIssue(BaseModel): message: str node_id: str | None = None path: str = "" + #: 引用写在节点配置的哪个字段里(prompt、cases[0].condition)。产出侧的问题为空 + field: str = "" class VariableReport(BaseModel): @@ -350,7 +352,7 @@ def analyze(spec: GraphSpec) -> VariableReport: if target.kind == "loop" and target.produced_by: if node.id not in bodies.get(target.produced_by, set()): report.issues.append(VarIssue( - level="warning", node_id=node.id, path=path, + level="warning", node_id=node.id, path=path, field=field, message=f"{{{{ {path} }}}} 是「{target.produced_by_label}」" f"每轮的循环变量,只在循环体内部有值;" f"这一步不在循环体里,取到的会是空值", @@ -364,7 +366,7 @@ def analyze(spec: GraphSpec) -> VariableReport: and target.order > order ): report.issues.append(VarIssue( - level="warning", node_id=node.id, path=path, + level="warning", node_id=node.id, path=path, field=field, message=f"{{{{ {path} }}}} 由「{target.produced_by_label}」产出," f"但那一步在这之后才跑,这里取到的会是空值", )) @@ -383,7 +385,7 @@ def analyze(spec: GraphSpec) -> VariableReport: hint = _did_you_mean(path, index) report.issues.append(VarIssue( level="error" if known_root else "warning", - node_id=node.id, path=path, + node_id=node.id, path=path, field=field, message=( f"{{{{ {path} }}}} 没有任何节点产出," f"是不是想写 {{{{ {hint} }}}}?取不到值会渲染成空字符串,不报错" diff --git a/backend/app/memory/embeddings.py b/backend/app/memory/embeddings.py index b2728d53..35988208 100644 --- a/backend/app/memory/embeddings.py +++ b/backend/app/memory/embeddings.py @@ -4,7 +4,7 @@ import hashlib import math import os import re -from typing import Any, Sequence +from typing import Any, NamedTuple, Sequence import numpy as np @@ -182,7 +182,7 @@ def has_semantics() -> bool: def _why(e: BaseException) -> str: - from app.api.errors import explain + from app.core.errors import explain return explain(e)[0] @@ -293,7 +293,18 @@ class BM25: return scores -def hybrid_rank( +class Ranked(NamedTuple): + """一条混合排序的结果。contrib 两项之和就是 score。""" + + index: int + score: float + #: 两路的原始分:余弦、BM25 + signals: dict[str, float] + #: 两路各自对总分的贡献:归一化之后乘上 α / 1-α + contrib: dict[str, float] + + +def hybrid_contrib( query: str, texts: Sequence[str], vectors: Sequence[np.ndarray | None], @@ -301,8 +312,8 @@ def hybrid_rank( *, alpha: float = 0.5, keyword_scores: Sequence[float] | None = None, -) -> list[tuple[int, float, dict[str, float]]]: - """向量相似度和 BM25 各归一化后加权合并。 +) -> list[Ranked]: + """向量相似度和 BM25 各归一化后加权合并,并交出每一路的贡献。 纯向量会漏掉精确的专有名词,纯 BM25 又抓不住同义表达, 混合排序在本地小知识库上稳定优于任何一个单独使用。 @@ -311,6 +322,9 @@ def hybrid_rank( 统计(N、avg_len、df)算好了分,而这里只拿到候选子集——在子集上重算, df 和平均长度全是另一套数,排出来的名次和全表扫不一样。真踩过, test_the_index_ranks_the_same_as_a_full_scan 就是为此写的。 + + 贡献要在这里算:归一化是在整个候选集上做的,调用方只拿得到前几条, + 复现不了。 """ if not texts: return [] @@ -328,10 +342,24 @@ def hybrid_rank( return [(x - lo) / (hi - lo) for x in xs] kw_n, vec_n = _norm(kw_scores), _norm(vec_scores) - ranked = [ - (i, alpha * vec_n[i] + (1 - alpha) * kw_n[i], - {"vector": vec_scores[i], "keyword": kw_scores[i]}) - for i in range(len(texts)) - ] - ranked.sort(key=lambda t: t[1], reverse=True) + ranked: list[Ranked] = [] + for i in range(len(texts)): + by_vec, by_kw = alpha * vec_n[i], (1 - alpha) * kw_n[i] + ranked.append(Ranked(i, by_vec + by_kw, {"vector": vec_scores[i], "keyword": kw_scores[i]}, + {"vector": by_vec, "keyword": by_kw})) + ranked.sort(key=lambda r: r.score, reverse=True) return ranked + + +def hybrid_rank( + query: str, + texts: Sequence[str], + vectors: Sequence[np.ndarray | None], + query_vec: np.ndarray, + *, + alpha: float = 0.5, + keyword_scores: Sequence[float] | None = None, +) -> list[tuple[int, float, dict[str, float]]]: + """同 hybrid_contrib,只要 (下标, 总分, 原始分)。""" + return [(r.index, r.score, r.signals) for r in hybrid_contrib( + query, texts, vectors, query_vec, alpha=alpha, keyword_scores=keyword_scores)] diff --git a/backend/app/memory/kb.py b/backend/app/memory/kb.py index 77d4d5e5..36855b18 100644 --- a/backend/app/memory/kb.py +++ b/backend/app/memory/kb.py @@ -10,7 +10,7 @@ from app.db.models import Chunk, Document from app.memory import inverted from app.memory.embeddings import ( default_alpha, embed_text, embed_texts, embedder_dim, embedder_id, from_blob, - hybrid_rank, to_blob, usable, + hybrid_contrib, to_blob, usable, ) _TARGET = 800 @@ -301,7 +301,7 @@ async def search( query_vec = await embed_text(query) vectors = [from_blob(r.embedding, embedder_dim()) for r in rows] - ranked = hybrid_rank( + ranked = hybrid_contrib( query, [r.content for r in rows], vectors, @@ -310,8 +310,11 @@ async def search( keyword_scores=kw_scores, ) out: list[dict[str, Any]] = [] - for idx, score, parts in ranked[:limit]: - row = rows[idx] + for hit in ranked[:limit]: + row = rows[hit.index] + score = round(hit.score, 4) + # 关键词那份用减法得出:各自四舍五入之后再相加,会和总分差出一个末位 + by_vec = min(round(hit.contrib["vector"], 4), score) out.append( { "chunk_id": row.id, @@ -319,18 +322,23 @@ async def search( "title": (row.meta or {}).get("title", ""), "ordinal": row.ordinal, "content": row.content, - "score": round(score, 4), - "signals": {k: round(v, 4) for k, v in parts.items()}, + "score": score, + "signals": {k: round(v, 4) for k, v in hit.signals.items()}, + # 两路各贡献了多少分,两者之和等于 score。调试台据此画贡献条 + "contrib": {"vector": by_vec, "keyword": round(score - by_vec, 4)}, } ) return out -async def reindex(session: AsyncSession, collection: str | None = None) -> dict[str, Any]: +async def reindex( + session: AsyncSession, collection: str | None = None, *, on_progress: Any = None, +) -> dict[str, Any]: """用当前 embedder 重算向量。 换了 embedding 模型之后唯一的恢复手段。按批做:一次几千条 chunk 全塞给 远端 embedding 接口,要么超时要么被限流,而中途失败又没有断点。 + on_progress(已完成, 总数) 每批提交之后回调一次。 """ stmt = select(Chunk) if collection: @@ -349,6 +357,8 @@ async def reindex(session: AsyncSession, collection: str | None = None) -> dict[ row.embed_model = model_id row.embed_dim = dim await session.commit() + if on_progress: + on_progress(min(i + batch, len(rows)), len(rows)) # 倒排一起重建:两者都是"存量数据升级"的恢复手段,分成两个按钮只会漏点一个 await inverted.rebuild(session, collection) return {"collection": collection, "reindexed": len(rows), "embedder": model_id} diff --git a/backend/app/memory/rerank.py b/backend/app/memory/rerank.py index 0f531d6d..ea06252e 100644 --- a/backend/app/memory/rerank.py +++ b/backend/app/memory/rerank.py @@ -96,7 +96,7 @@ async def rerank( return hits[:top_n] except Exception as e: # noqa: BLE001 if on_note: - from app.api.errors import explain + from app.core.errors import explain on_note(f"重排失败,按初筛顺序返回:{explain(e)[0]}") return hits[:top_n] diff --git a/backend/app/memory/store.py b/backend/app/memory/store.py index e731739f..5d150666 100644 --- a/backend/app/memory/store.py +++ b/backend/app/memory/store.py @@ -232,11 +232,14 @@ async def stale_count(session: AsyncSession, scope: str | None = None) -> int: return int((await session.execute(stmt)).scalar_one() or 0) -async def reindex(session: AsyncSession, scope: str | None = None) -> int: +async def reindex( + session: AsyncSession, scope: str | None = None, *, on_progress: Any = None, +) -> int: """用当前 embedder 重算记忆的向量,返回重算了多少条。 换 embedding 模型之后记忆和知识库一起失效,但在此之前只有知识库有重建 路径——记忆这边悄悄退回关键词,而且没有任何恢复手段。 + on_progress(已完成, 总数) 每批提交之后回调一次。 """ stmt = select(MemoryItem) if scope: @@ -246,9 +249,9 @@ async def reindex(session: AsyncSession, scope: str | None = None) -> int: return 0 model_id, dim = embedder_id(), embedder_dim() - from app.memory.kb import _EMBED_BATCH + from app.memory import kb - batch = _EMBED_BATCH # 和 kb 那边统一:一次几千条全发给远端接口必被限流 + batch = kb._EMBED_BATCH # 和 kb 那边统一:一次几千条全发给远端接口必被限流 for i in range(0, len(rows), batch): part = rows[i:i + batch] vectors = await embed_texts([r.content for r in part]) @@ -257,6 +260,8 @@ async def reindex(session: AsyncSession, scope: str | None = None) -> int: row.embed_model = model_id row.embed_dim = dim await session.commit() + if on_progress: + on_progress(min(i + batch, len(rows)), len(rows)) return len(rows) diff --git a/backend/app/tools/builtin/web.py b/backend/app/tools/builtin/web.py index 50119e7d..6f9d4aec 100644 --- a/backend/app/tools/builtin/web.py +++ b/backend/app/tools/builtin/web.py @@ -175,7 +175,7 @@ async def web_search(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: results = await _duckduckgo(args.query, args.max_results) engine = "duckduckgo" except Exception as e: # noqa: BLE001 - 搜索失败不该让整个 run 挂掉 - from app.api.errors import explain + from app.core.errors import explain return {"error": f"搜索失败:{explain(e)[0]}", "results": []} return {"engine": engine, "query": args.query, "results": results} diff --git a/backend/app/tools/custom.py b/backend/app/tools/custom.py index 8cbc2795..69e0bcbf 100644 --- a/backend/app/tools/custom.py +++ b/backend/app/tools/custom.py @@ -13,7 +13,7 @@ from app.engine.expressions import render_deep, render_template from app.sandbox.base import SandboxLimits from app.sandbox.manager import sandbox_manager from app.tools.net import UnsafeUrlError, safe_request -from app.tools.registry import ToolContext +from app.tools.registry import ToolBuildError, ToolContext, prepare_args _TYPES = { "string": str, @@ -24,6 +24,22 @@ _TYPES = { "object": dict, } +#: 手写参数定义时常见的类型写法(Python 的、其他语言的、缩写)。只用来在报错里 +#: 猜「是不是想写 X」,不自动放行:悄悄按猜的类型建出来,试跑和运行对不上还没人知道 +_TYPE_ALIASES = { + "int": "integer", "long": "integer", "float": "number", "double": "number", + "decimal": "number", "str": "string", "text": "string", "bool": "boolean", + "list": "array", "dict": "object", "map": "object", "json": "object", +} + + +def _type_guess(value: Any) -> str | None: + """value 像是哪个合法类型;认不出来返回 None。""" + if not isinstance(value, str): + return None + low = value.strip().lower() + return low if low in _TYPES else _TYPE_ALIASES.get(low) + def schema_to_model(name: str, schema: dict[str, Any]) -> type[BaseModel]: """把 JSON Schema 转成 pydantic 模型,好让它能绑成 LangChain 工具。""" @@ -42,6 +58,72 @@ def schema_to_model(name: str, schema: dict[str, Any]) -> type[BaseModel]: return create_model(f"{name}_Args", **fields) # type: ignore[call-overload] +def schema_problem(schema: Any) -> str | None: + """参数定义哪里写得不对;没问题返回 None。 + + 参数定义是用户在编辑器里手写的 JSON。schema_to_model 只认 {"type": "integer"} + 这样的写法,写成 "int" 或 {"type": "int"} 时要么炸在 .get 上、要么悄悄当成 + 字符串——前者会被说成「后端内部出错了」,后者试跑通过、到了模型那边类型不对。 + """ + if not isinstance(schema, dict): + return "参数定义要是一个 JSON 对象" + props = schema.get("properties") + if props is not None and not isinstance(props, dict): + return '参数定义格式不对:properties 要是一个对象,每个参数一项,比如 {"n": {"type": "integer"}}' + for key, spec in (props or {}).items(): + if not str(key).strip(): + return "参数定义格式不对:有一个参数名是空的,给它起个名字" + if str(key).startswith("_"): + return (f"参数定义格式不对:参数名 {key} 不能以下划线开头," + f"换成 {str(key).lstrip('_') or 'value'} 这样的名字") + if str(key).startswith("model_") and hasattr(BaseModel, str(key)): + return f"参数定义格式不对:参数名 {key} 和内部保留的名字冲突,换一个名字" + if not isinstance(spec, dict): + # 示例照着他写的那个类型给,照抄就能改好:写了 "string" 却让人改成 integer 只会添乱 + shown = json.dumps(spec, ensure_ascii=False) + meant = _type_guess(spec) or "string" + return (f'参数定义格式不对:参数 {key} 要写成 {{"type": "{meant}"}} 这样的对象,' + f"不能直接写 {shown}") + kind = spec.get("type", "string") + if not isinstance(kind, str) or kind not in _TYPES: + shown = kind if isinstance(kind, str) else json.dumps(kind, ensure_ascii=False) + meant = _type_guess(kind) + guess = f",是不是想写 {meant}" if meant else "" + return (f"参数定义格式不对:参数 {key} 的类型「{shown}」认不出来{guess};" + f"只能是 {'、'.join(_TYPES)} 里的一个") + if not isinstance(spec.get("description", ""), str): + return f"参数定义格式不对:参数 {key} 的 description 要是一段文字" + required = schema.get("required") + if required is not None and ( + not isinstance(required, list) or not all(isinstance(k, str) for k in required) + ): + return '参数定义格式不对:required 要是参数名的列表,比如 ["n"]' + # 上面没认出来的写法,最后按运行时同一个转换真建一次:放行的一定建得出来 + import warnings + + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # 和 BaseModel 同名的参数(json、copy)只是警告,照样能用 + schema_to_model("probe", schema) + except Exception: # noqa: BLE001 + return "参数定义格式不对:按它建不出工具的参数表,检查参数名和每个参数的 type" + return None + + +def broken_tool_message(name: str, problem: str) -> str: + """库里存着的坏工具被节点绑上时的报错:哪个工具、坏在哪、去哪里改。""" + return f"自定义工具「{name}」的{problem}。到「工具」页把它的参数定义改好再运行" + + +class ToolFailure(dict): + """工具自己报的失败:沙箱里代码出错、地址被拦、请求体不是 JSON。 + + 运行时照旧当成 {"error": …} 交给模型——它看了会换个参数再试;但试跑时要 + 认得出这是失败,不能在界面上标「成功」再贴一段报错。所以是个 dict 的子类: + 序列化出来和原来一模一样,只多了一个能 isinstance 的身份。 + """ + + async def _run_http(row: CustomTool, args: dict[str, Any]) -> Any: """HTTP 模板工具:url / headers / body 里可以用 {{ 参数名 }} 插值。""" config = row.config or {} @@ -62,9 +144,9 @@ async def _run_http(row: CustomTool, args: dict[str, Any]) -> Any: timeout=config.get("timeout"), ) except UnsafeUrlError as e: - return {"error": str(e)} + return ToolFailure(error=str(e)) except json.JSONDecodeError as e: - return {"error": f"请求体不是合法 JSON:{e}"} + return ToolFailure(error=f"请求体不是合法 JSON:{e}") text = str(result.get("body", "")) if config.get("parse_json"): @@ -90,7 +172,7 @@ async def _run_python(row: CustomTool, args: dict[str, Any], ctx: ToolContext) - session_id=ctx.sandbox_session, ) if not result.ok: - return {"error": result.error or result.stderr or "执行失败", "exit_code": result.exit_code} + return ToolFailure(error=result.error or result.stderr or "执行失败", exit_code=result.exit_code) out = result.stdout.strip() try: return json.loads(out) @@ -110,6 +192,14 @@ async def build_custom_tools( ) ).scalars() ) + # 保存时已经拒掉写坏的参数定义,但那道关卡之前存进去的还在库里。不交给 + # schema_to_model 去炸——那样节点只能报一句「'str' object has no attribute 'get'」, + # 看不出是哪个工具。也不悄悄跳过它接着跑:少了一个它指望的工具,模型只会 + # 假装调过、编出一个答案。绑了它的节点失败,协作成员这一步记为失败交给调度者 + broken = [broken_tool_message(row.name, problem) for row in rows + if (problem := schema_problem(row.parameters or {}))] + if broken: + raise ToolBuildError(";".join(broken)) tools: list[BaseTool] = [] for row in rows: args_model = schema_to_model(row.name, row.parameters or {}) @@ -138,14 +228,16 @@ async def build_custom_tools( return tools -async def run_custom_tool( - session: AsyncSession, name: str, args: dict[str, Any], ctx: ToolContext -) -> Any: - row = ( - await session.execute(select(CustomTool).where(CustomTool.name == name)) - ).scalar_one_or_none() - if not row: - raise KeyError(f"找不到自定义工具 {name!r}") +async def trial(row: CustomTool, args: dict[str, Any], ctx: ToolContext) -> tuple[Any, str | None]: + """试跑一次,参数的处理和运行时一样:先按参数 schema 校验、纠正,再执行。 + + row 可以是还没保存的草稿。以前试跑直接把参数原样塞进去,运行时却要过 + schema——试跑通过的参数,到了工作流里被拒。参数不对抛 ToolArgsError。 + + 返回 (结果, 纠正说明或 None)。参数名写错被替换时,运行时会把说明报出去; + 试跑也得报,否则用户照着一份「跑通了」的错参数去配工作流。 + """ + payload, note = prepare_args(schema_to_model(row.name or "draft", row.parameters or {}), args) if row.kind == "python": - return await _run_python(row, args, ctx) - return await _run_http(row, args) + return await _run_python(row, payload, ctx), note + return await _run_http(row, payload), note diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index 80bcc96e..fae3fe23 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -20,7 +20,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.data import introspect -from app.data.engine import run_query +from app.data.engine import query_timeout, run_query from app.data.guard import QueryLimits, SqlRejected, is_write from app.db.models import DataSource from app.tools.registry import ToolContext @@ -123,8 +123,12 @@ async def build_datasource_tools( def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool: + # 数据源在 options.query_timeout_s 里配了就用它,否则取缺省。数据库按它停下语句, + # 引擎按它告诉界面上限是多少(metadata["timeout_s"]) + seconds = query_timeout(source) + async def _run(sql: str, limit: int | None = None) -> str: - limits = QueryLimits(max_rows=int(limit)) if limit else QueryLimits() + limits = QueryLimits(timeout_seconds=seconds, **({"max_rows": int(limit)} if limit else {})) try: result = await run_query(source, sql, limits=limits) except SqlRejected as e: @@ -132,7 +136,7 @@ def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool: # 给一句笼统的"失败了"只会让它瞎猜 return f"SQL 被拒绝:{e}" except Exception as e: # noqa: BLE001 - from app.api.errors import explain, first_line + from app.core.errors import explain, first_line # 驱动的原话留着("no such table: x" 正是模型改写 SQL 要的线索), # 类名和 SQLAlchemy 的包装前缀去掉:这一行也会原样出现在运行面板上 @@ -159,7 +163,8 @@ def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool: coroutine=_run, func=None, # 同一个工具,危不危险看这一次的 SQL。审批关卡经 registry.call_is_dangerous 问它 - metadata={"dangerous_if": lambda args: is_dangerous_call(source, str(args.get("sql") or ""))}, + metadata={"dangerous_if": lambda args: is_dangerous_call(source, str(args.get("sql") or "")), + "timeout_s": seconds}, ) diff --git a/backend/app/tools/mcp_manager.py b/backend/app/tools/mcp_manager.py index cf39313b..6e16071c 100644 --- a/backend/app/tools/mcp_manager.py +++ b/backend/app/tools/mcp_manager.py @@ -1,7 +1,9 @@ from __future__ import annotations import asyncio +import json import logging +import time from typing import Any from langchain_core.tools import BaseTool @@ -24,64 +26,110 @@ def _server_config(row: McpServer) -> dict[str, Any]: } +def _config_key(row: McpServer) -> str: + """一个服务的连接配置,缓存据此判断手上的工具是不是按现在这份配置建的。""" + return json.dumps(_server_config(row), sort_keys=True, ensure_ascii=False) + + class McpManager: """管理外部 MCP server 的连接与工具缓存。 工具在图里用 `mcp:/` 引用。连接是懒建立的, 并且按 server 缓存,避免每个节点都去重新拉起一次 stdio 子进程。 + + 缓存按服务逐个补、逐个作废。以前只在「一个都没加载过」时整批建一次:改了 + 启动命令,运行照样用旧命令建出来的工具;删掉的服务照样调得到;缓存建好之后 + 新加的服务,工具列表里一直没有它,除非有人去点刷新。现在每次取工具都先对一遍 + 库里启用着的服务:缺的连上,配置变了的重连,删了、停用了的不再交出去。 """ def __init__(self) -> None: self._tools: dict[str, list[BaseTool]] = {} # server name -> tools + #: 每个服务的工具是按哪份连接配置建的(_config_key) + self._built_from: dict[str, str] = {} self._lock = asyncio.Lock() - async def _load_servers(self) -> list[McpServer]: + def invalidate(self, *names: str) -> None: + """服务改了、删了:丢掉按旧配置建的工具,下次用到时再连。""" + for name in names: + self._tools.pop(name, None) + self._built_from.pop(name, None) + + def _fresh(self, row: McpServer) -> bool: + return row.name in self._tools and self._built_from.get(row.name) == _config_key(row) + + async def _load_servers(self, names: list[str] | None = None) -> list[McpServer]: async with SessionLocal() as session: - rows = await session.execute(select(McpServer).where(McpServer.enabled.is_(True))) + query = select(McpServer).where(McpServer.enabled.is_(True)) + if names is not None: + query = query.where(McpServer.name.in_(names)) + rows = await session.execute(query) return list(rows.scalars()) - async def _connect(self, rows: list[McpServer]) -> dict[str, list[BaseTool]]: + async def _connect(self, rows: list[McpServer]) -> None: from langchain_mcp_adapters.client import MultiServerMCPClient - out: dict[str, list[BaseTool]] = {} for row in rows: + key = _config_key(row) + started = time.perf_counter() try: client = MultiServerMCPClient({row.name: _server_config(row)}) - tools = await asyncio.wait_for(client.get_tools(), timeout=30) - out[row.name] = list(tools) - await self._mark(row.id, "ok", None, [t.name for t in tools]) + tools = list(await asyncio.wait_for(client.get_tools(), timeout=30)) + status, error = "ok", None except Exception as e: # noqa: BLE001 - 一个 server 挂了不影响其他的 logger.warning("MCP server %s 连接失败: %s", row.name, e) - out[row.name] = [] - await self._mark(row.id, "error", _reason(e), []) - return out + tools, status, error = [], "error", _reason(e, row.command) + # 连不上也缓存成空:不然每次取工具都要再等一轮 30 秒超时。改配置或刷新时重连 + self._tools[row.name] = tools + self._built_from[row.name] = key + await self._mark(row.id, key, status, error, [t.name for t in tools], started) async def _mark( - self, server_id: str, status: str, error: str | None, tool_names: list[str] + self, server_id: str, key: str, status: str, error: str | None, tool_names: list[str], + started: float, ) -> None: + from app.api.health import record + async with SessionLocal() as session: row = await session.get(McpServer, server_id) - if row: + # 连到一半服务被改了:这次连的是旧配置,它的结论不能盖掉改完之后的「未检查」 + if row and _config_key(row) == key: row.status = status row.last_error = error row.tools_cache = tool_names + # 加载工具本身就是一次连接:记下来,工具页不用再单独测一次才知道 + row.last_check = record( + status == "ok", int((time.perf_counter() - started) * 1000), error) await session.commit() + async def _ensure(self, rows: list[McpServer]) -> None: + """rows 里还没连过、或者是按旧配置连的,连一次补上。""" + if all(self._fresh(r) for r in rows): + return + async with self._lock: + # 等锁的时候别人可能已经连好了 + stale = [r for r in rows if not self._fresh(r)] + if stale: + await self._connect(stale) + async def refresh(self) -> dict[str, list[str]]: + """全部重连一遍(服务端的工具变了,配置没变时用)。""" async with self._lock: rows = await self._load_servers() - self._tools = await self._connect(rows) - return {name: [t.name for t in tools] for name, tools in self._tools.items()} + self.invalidate(*(set(self._tools) - {r.name for r in rows})) + await self._connect(rows) + return {r.name: [t.name for t in self._tools.get(r.name, [])] for r in rows} async def ensure_loaded(self) -> None: - if not self._tools: - await self.refresh() + await self._ensure(await self._load_servers()) async def list_tools(self) -> list[dict[str, Any]]: - await self.ensure_loaded() + rows = await self._load_servers() + await self._ensure(rows) out: list[dict[str, Any]] = [] - for server, tools in self._tools.items(): - for tool in tools: + for row in rows: + server = row.name + for tool in self._tools.get(server, []): out.append( { "id": f"mcp:{server}/{tool.name}", @@ -97,13 +145,21 @@ class McpManager: return out async def get_tools(self, refs: list[str]) -> list[BaseTool]: - """按 `mcp:/` 取工具;`mcp:/*` 表示该 server 全部工具。""" - await self.ensure_loaded() - wanted: list[BaseTool] = [] + """按 `mcp:/` 取工具;`mcp:/*` 表示该 server 全部工具。 + + 只交出库里还在、并且启用着的服务的工具:删了、停用了的,缓存里就算还有也不给。 + """ + parsed = [] for ref in refs: body = ref[4:] if ref.startswith("mcp:") else ref server, _, tool_name = body.partition("/") - tools = self._tools.get(server, []) + parsed.append((server, tool_name)) + rows = await self._load_servers(sorted({server for server, _ in parsed})) + await self._ensure(rows) + live = {r.name for r in rows} + wanted: list[BaseTool] = [] + for server, tool_name in parsed: + tools = self._tools.get(server, []) if server in live else [] if tool_name in ("", "*"): wanted.extend(tools) continue @@ -127,20 +183,22 @@ class McpManager: ], } except Exception as e: # noqa: BLE001 - from app.api.errors import raw + from app.core.errors import raw - return {"ok": False, "error": _reason(e), "detail": raw(e)} + return {"ok": False, "error": _reason(e, row.command), "detail": raw(e)} -def _reason(e: BaseException) -> str: +def _reason(e: BaseException, command: str | None = None) -> str: """连不上 MCP 服务的原因,写成工具页上看得懂的话。 最常见的两种:命令不存在(没装 npx / uvx)和超时(进程起来了但不讲 MCP)。 """ - from app.api.errors import explain + from app.core.errors import explain if isinstance(e, FileNotFoundError): - return f"启动不了:找不到命令 {e.filename or ''}。确认它已安装、并且在后端的 PATH 里" + # stdio 客户端抛的这个异常常常不带文件名,只剩「找不到命令 」半句:补上配置的命令 + missing = e.filename or command or "" + return f"启动不了:找不到命令 {missing}。确认它已安装、并且在后端的 PATH 里" if isinstance(e, asyncio.TimeoutError): return "连上了但 30 秒内没有应答:确认这个命令启动的是 MCP 服务,参数有没有写错" reason, hint = explain(e) diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 82c8d90a..d4cd7a99 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -166,6 +166,14 @@ async def build_tools( return tools +class ToolBuildError(ValueError): + """绑定的工具建不出来:它自己的配置写坏了(比如自定义工具的参数定义)。 + + message 直接给人看:是哪个工具、坏在哪、去哪里改。节点据此报一句清楚的错, + 而不是让 schema 转换在半路炸出一句「'str' object has no attribute 'get'」。 + """ + + class ToolArgsError(ValueError): """工具参数对不上 schema。 diff --git a/backend/tests/test_api_messages.py b/backend/tests/test_api_messages.py index 6b6a69ef..b2295684 100644 --- a/backend/tests/test_api_messages.py +++ b/backend/tests/test_api_messages.py @@ -38,7 +38,7 @@ def _clean(text: str) -> None: def test_common_failures_are_put_in_plain_words(): import httpx - from app.api.errors import explain, graph_error, raw + from app.core.errors import explain, graph_error, raw reason, hint = explain(httpx.ConnectError("[Errno 61] Connection refused")) assert "连不上" in reason and hint diff --git a/backend/tests/test_conversation_status.py b/backend/tests/test_conversation_status.py new file mode 100644 index 00000000..3b37ca5a --- /dev/null +++ b/backend/tests/test_conversation_status.py @@ -0,0 +1,195 @@ +"""会话列表认得每个会话的最后一轮停在哪;轮次的可信度元数据有自己的一列。 + +以前左栏只能给「这次打开页面以来取回过内存」的会话标状态。没打开过的会话, +哪怕最后一轮正停在审批上、或者已经失败,列表里也和正常完成的长得一样—— +人得一个个点进去才知道哪里在等他。 + +元数据(出具档位、运行类别、未查库、结局、耗时…)以前挂在 review.meta 下, +于是没复核过的轮次 review 也不为空,「没复核过」和「复核过、没问题」分不开。 +""" +from __future__ import annotations + +from datetime import timedelta + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.base import SessionLocal, utcnow +from app.db.models import Approval, ConversationTurn, Run +from app.main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def _conversation(client, title: str) -> str: + r = await client.post("/api/conversations", json={"kind": "chat", "title": title}) + assert r.status_code == 201, r.text + return r.json()["id"] + + +async def _turn(client, conv_id: str, question: str = "订单数多少", **patch) -> dict: + r = await client.post(f"/api/conversations/{conv_id}/turns", json={"question": question}) + assert r.status_code == 201, r.text + turn = r.json() + if patch: + r = await client.patch(f"/api/conversations/{conv_id}/turns/{turn['id']}", json=patch) + assert r.status_code == 200, r.text + turn = r.json() + return turn + + +async def _run(status: str, *, pending: bool = False) -> str: + async with SessionLocal() as session: + run = Run(workflow_name="状态测试", status=status) + session.add(run) + await session.flush() + if pending: + session.add(Approval(run_id=run.id, node_id="gate", status="pending")) + await session.commit() + return run.id + + +async def _listed(client) -> dict[str, dict]: + r = await client.get("/api/conversations") + assert r.status_code == 200, r.text + return {c["id"]: c for c in r.json()} + + +# -------------------------------------------------------------------------- +# REQ-1:meta 自己一列 +# -------------------------------------------------------------------------- + +META = {"v": 1, "runClass": "exploratory", "noQuery": True, "ms": 8200, "queries": 0} + + +async def test_turn_meta_is_stored_and_read_back_on_its_own(client): + conv = await _conversation(client, "元数据") + turn = await _turn(client, conv, meta=META, status="done", answer="12 家") + assert turn["meta"] == META + assert turn["review"] is None, "只写了 meta,review 得还是「没复核过」" + + detail = (await client.get(f"/api/conversations/{conv}")).json() + assert detail["turns"][0]["meta"] == META + + +async def test_patching_review_and_meta_do_not_overwrite_each_other(client): + conv = await _conversation(client, "互不覆盖") + turn = await _turn(client, conv, meta=META) + review = {"verdict": "annotated", "note": "检索降级"} + r = await client.patch(f"/api/conversations/{conv}/turns/{turn['id']}", json={"review": review}) + assert r.json()["meta"] == META + r = await client.patch(f"/api/conversations/{conv}/turns/{turn['id']}", + json={"meta": {**META, "ms": 9000}}) + assert r.json()["review"] == review + assert r.json()["meta"]["ms"] == 9000 + + +async def test_old_turns_with_meta_under_review_still_read_as_meta(client): + """迁移之前写进去的:meta 挂在 review 下。读的时候由后端兜底,前端只认 turn.meta。""" + conv = await _conversation(client, "老数据") + legacy = {"verdict": "ok", "meta": {"v": 1, "outcome": "cancelled"}} + turn = await _turn(client, conv, review=legacy, status="error", error="已取消") + assert turn["meta"] == {"v": 1, "outcome": "cancelled"} + # 新列一旦写过就以它为准 + r = await client.patch(f"/api/conversations/{conv}/turns/{turn['id']}", json={"meta": META}) + assert r.json()["meta"] == META + + +# -------------------------------------------------------------------------- +# REQ-2:列表里的最后一轮状态 +# -------------------------------------------------------------------------- + + +async def test_the_list_says_where_each_conversation_last_stopped(client): + cases: dict[str, tuple[str, str | None]] = {} + + conv = await _conversation(client, "完成") + run = await _run("succeeded") + await _turn(client, conv, run_id=run, status="done", answer="12") + cases[conv] = ("done", run) + + conv = await _conversation(client, "失败") + run = await _run("failed") + await _turn(client, conv, run_id=run, status="error", error="查询超时") + cases[conv] = ("error", run) + + conv = await _conversation(client, "取消") + await _turn(client, conv, status="error", error="已取消") + cases[conv] = ("cancelled", None) + + conv = await _conversation(client, "重启挂起") + run = await _run("interrupted") + await _turn(client, conv, run_id=run, status="error", error="服务重启,这一轮中断了", + meta={"v": 1, "outcome": "suspended", "runId": run}) + cases[conv] = ("suspended", run) + + conv = await _conversation(client, "在跑") + run = await _run("running") + await _turn(client, conv, run_id=run) + cases[conv] = ("running", run) + + conv = await _conversation(client, "等审批") + run = await _run("interrupted", pending=True) + await _turn(client, conv, run_id=run) + cases[conv] = ("waiting", run) + + conv = await _conversation(client, "停着没审批") + run = await _run("interrupted") + await _turn(client, conv, run_id=run) + cases[conv] = ("suspended", run) + + conv = await _conversation(client, "页面关了但跑完了") + run = await _run("succeeded") + await _turn(client, conv, run_id=run) + cases[conv] = ("done", run) + + conv = await _conversation(client, "页面关了而且失败了") + run = await _run("failed") + await _turn(client, conv, run_id=run) + cases[conv] = ("error", run) + + conv = await _conversation(client, "正在建图") + await _turn(client, conv) + cases[conv] = ("running", None) + + listed = await _listed(client) + got = {cid: (listed[cid]["last_status"], listed[cid]["last_run_id"]) for cid in cases} + assert got == cases + + +async def test_only_the_last_turn_counts(client): + conv = await _conversation(client, "后来好了") + await _turn(client, conv, status="error", error="查询超时") + run = await _run("succeeded") + await _turn(client, conv, run_id=run, status="done", answer="好了") + assert (await _listed(client))[conv]["last_status"] == "done" + + +async def test_a_build_abandoned_long_ago_is_not_shown_as_running(client): + """建图是跟着浏览器那条流走的:页面关了就没了,轮次却一直停在 running。""" + conv = await _conversation(client, "建到一半关了") + turn = await _turn(client, conv) + async with SessionLocal() as session: + row = await session.get(ConversationTurn, turn["id"]) + row.updated_at = utcnow() - timedelta(hours=2) + await session.commit() + assert (await _listed(client))[conv]["last_status"] == "error" + + +async def test_an_empty_conversation_has_no_status(client): + conv = await _conversation(client, "空的") + item = (await _listed(client))[conv] + assert item["last_status"] is None and item["last_run_id"] is None + + +async def test_the_detail_carries_the_same_status(client): + conv = await _conversation(client, "详情也带") + run = await _run("interrupted", pending=True) + await _turn(client, conv, run_id=run) + detail = (await client.get(f"/api/conversations/{conv}")).json() + assert (detail["last_status"], detail["last_run_id"]) == ("waiting", run) diff --git a/backend/tests/test_copilot_merge.py b/backend/tests/test_copilot_merge.py new file mode 100644 index 00000000..1e24fd1e --- /dev/null +++ b/backend/tests/test_copilot_merge.py @@ -0,0 +1,497 @@ +"""助手改节点是合并,不是整体替换;工具绑定变了要说出来。 + +起因是一次真实运行:助手用 update_node 改写了一个查库的 agent 和一个协作团队。 +协议写的是「config 整体替换」,模型只写了要改的提示词、没写 tools, +于是三个要查库的 agent 全部丢了工具——查库的 agent 只能写「假设调用工具」, +协作成员把模型自带的工具调用标记当文字吐了 4 轮,最后一堆原始标记被当成答案 +交了出去,数据库一次都没查。 + +这里守三件事: +1. update_node 只写要改的字段,没写的保留;显式写 null 才删。协作成员按 name + 合并,成员条目里没写 tools 的保留它原来的 tools。 +2. 给模型看的协议文案和合并语义一致,不再说「整体替换」。 +3. 改完之后工具集合变小、而指令里没让删工具的,自查给 warning,final 里带 + 「工具绑定变化」的回执。 + +示例图全是通用名(db_query__shop、orders),不含任何真实库名。 +""" +from __future__ import annotations + +import copy +import json + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +QUERY_TOOLS = ["db_query__shop", "db_schema__shop"] + +#: 第 1 轮搭好的图:一个查库的 agent,一个带成员的协作团队。工具都绑好了 +ROUND_ONE = { + "nodes": [ + {"id": "start", "type": "input", "position": {"x": 0, "y": 0}, + "data": {"label": "输入", "config": {}}}, + {"id": "query_data", "type": "agent", "position": {"x": 300, "y": 0}, + "data": {"label": "数据查询", "config": { + "system": "你是取数助手。", + "prompt": "查 orders 表里每个门店的订单数", + "tools": list(QUERY_TOOLS), + "assign_to": "rows", + }}}, + {"id": "review_team", "type": "supervisor", "position": {"x": 600, "y": 0}, + "data": {"label": "复核团队", "config": { + "goal": "核对订单数", + "agents": [ + {"name": "取数员", "description": "负责查库", "system": "只用 SQL 取数", + "tools": ["db_query__shop"]}, + {"name": "撰稿员", "description": "写结论", "system": "写一段结论", + "tools": []}, + ], + "max_rounds": 4, + }}}, + {"id": "out", "type": "output", "position": {"x": 900, "y": 0}, + "data": {"label": "成果", "config": { + "fields": [{"name": "answer", "value": "{{ vars.rows }}"}]}}}, + ], + "edges": [ + {"source": "start", "target": "query_data"}, + {"source": "query_data", "target": "review_team"}, + {"source": "review_team", "target": "out"}, + ], +} + +#: 第 2 轮模型实际发的:两条 update_node,都只写了提示词,没写 tools +ROUND_TWO_OPS = [ + {"op": "plan", "summary": "沿用上一轮的图,按月份拆"}, + {"op": "update_node", "id": "query_data", "label": "数据查询", + "config": {"system": "你是取数助手,按月份汇总。", "prompt": "按月份统计 orders 的订单数"}}, + {"op": "update_node", "id": "review_team", + "config": {"goal": "核对按月的订单数", + "agents": [{"name": "取数员", "system": "按月份取数"}, + {"name": "撰稿员", "system": "按月写结论"}]}}, + {"op": "done", "explanation": "改成按月份统计", "run": True}, +] + + +class _Scripted: + """第一轮吐 first;收到自查请求时吐 fix(默认什么都不改)。""" + + def __init__(self, first: list[dict], fix: list[dict] | None = None) -> None: + self.first = [json.dumps(o, ensure_ascii=False) for o in first] + self.fix = [json.dumps(o, ensure_ascii=False) for o in (fix or [{"op": "done"}])] + self.calls: list[str] = [] + + async def astream(self, messages): + from langchain_core.messages import AIMessageChunk + + human = next(text for role, text in messages if role == "human") + self.calls.append(human) + for line in (self.fix if "上线前自查" in human else self.first): + yield AIMessageChunk(content=line + "\n") + + +async def _stream(client, monkeypatch, model, body: dict) -> list[dict]: + import app.api.copilot as copilot + + async def _model(*_a, **_k): + return model, "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + events: list[dict] = [] + async with client.stream("POST", "/api/copilot/generate-stream", json=body) as r: + assert r.status_code == 200, await r.aread() + async for line in r.aiter_lines(): + if line.startswith("data: "): + events.append(json.loads(line[6:])) + return events + + +def _node(graph: dict, node_id: str) -> dict: + return next(n for n in graph["nodes"] if n["id"] == node_id) + + +def _config(graph: dict, node_id: str) -> dict: + return _node(graph, node_id)["data"]["config"] + + +def _member(graph: dict, node_id: str, name: str) -> dict: + return next(a for a in _config(graph, node_id)["agents"] if a["name"] == name) + + +async def _chat_with_round_one(client) -> str: + r = await client.post("/api/conversations", json={"kind": "chat"}) + conv_id = r.json()["id"] + r = await client.post(f"/api/conversations/{conv_id}/turns", json={"question": "每个门店多少单"}) + turn_id = r.json()["id"] + r = await client.patch(f"/api/conversations/{conv_id}/turns/{turn_id}", json={ + "graph": ROUND_ONE, "answer": "一共 12 家店", "status": "done", + }) + assert r.status_code == 200, r.text + return conv_id + + +# -------------------------------------------------------------------------- +# 1. 还原那次运行:问数据页的追问,模型只发了不带 tools 的 update_node +# -------------------------------------------------------------------------- + + +async def test_a_follow_up_that_rewrites_prompts_keeps_every_tool(client, monkeypatch): + conv_id = await _chat_with_round_one(client) + events = await _stream(client, monkeypatch, _Scripted(ROUND_TWO_OPS), { + "instruction": "按月份拆一下", "intent": "answer", "conversation_id": conv_id, + }) + final = events[-1] + assert final["op"] == "final", final + graph = final["graph"] + + agent = _config(graph, "query_data") + assert agent["tools"] == QUERY_TOOLS, "只改提示词不能把工具改没了" + assert agent["assign_to"] == "rows", "没写的键要保留" + assert agent["prompt"] == "按月份统计 orders 的订单数", "写了的键要真的改上去" + + team = _config(graph, "review_team") + assert team["goal"] == "核对按月的订单数" + assert team["max_rounds"] == 4 + fetcher = _member(graph, "review_team", "取数员") + assert fetcher["tools"] == ["db_query__shop"], "成员条目没写 tools,要保留成员原来的" + assert fetcher["system"] == "按月份取数" + assert fetcher["description"] == "负责查库", "成员没写的字段也保留" + assert [a["name"] for a in team["agents"]] == ["取数员", "撰稿员"] + + # 工具一个都没变:回执是空的,也不该有「工具被删了」的警告 + assert final["tool_changes"] == [] + assert not [i for i in final["issues"] if i.get("code") == "tools_dropped"] + + +async def test_the_same_edit_on_the_canvas_keeps_every_tool(client, monkeypatch): + """画布那条路径(带 base_graph)是同一套合并。""" + events = await _stream(client, monkeypatch, _Scripted(ROUND_TWO_OPS), { + "instruction": "按月份拆一下", "base_graph": copy.deepcopy(ROUND_ONE), + }) + final = events[-1] + assert final["op"] == "final", final + assert _config(final["graph"], "query_data")["tools"] == QUERY_TOOLS + assert _member(final["graph"], "review_team", "取数员")["tools"] == ["db_query__shop"] + assert final["tool_changes"] == [] + + +async def test_the_streamed_update_op_is_forwarded_as_the_model_wrote_it(client, monkeypatch): + """转给前端的操作不改写:前端按同一套合并规则自己落到画布上。""" + events = await _stream(client, monkeypatch, _Scripted(ROUND_TWO_OPS), { + "instruction": "按月份拆一下", "base_graph": copy.deepcopy(ROUND_ONE), + }) + ups = [e for e in events if e.get("op") == "update_node"] + assert [u["id"] for u in ups] == ["query_data", "review_team"] + assert "tools" not in ups[0]["config"] + + +# -------------------------------------------------------------------------- +# 2. 合并规则本身 +# -------------------------------------------------------------------------- + + +def _apply(graph: dict, op: dict) -> dict[str, dict]: + from app.api.copilot import _apply_op + + nodes = {n["id"]: copy.deepcopy(n) for n in graph["nodes"]} + edges = copy.deepcopy(graph["edges"]) + assert _apply_op(nodes, edges, op) is True + return nodes + + +def test_null_deletes_a_key_and_unmentioned_keys_stay(): + nodes = _apply(ROUND_ONE, {"op": "update_node", "id": "query_data", + "config": {"assign_to": None, "temperature": 0.2}}) + cfg = nodes["query_data"]["data"]["config"] + assert "assign_to" not in cfg + assert cfg["temperature"] == 0.2 + assert cfg["tools"] == QUERY_TOOLS + assert cfg["prompt"] == "查 orders 表里每个门店的订单数" + + +def test_explicit_tools_replace_the_old_list(): + nodes = _apply(ROUND_ONE, {"op": "update_node", "id": "query_data", + "config": {"tools": ["db_query__shop"]}}) + assert nodes["query_data"]["data"]["config"]["tools"] == ["db_query__shop"] + + +def test_members_merge_by_name_and_can_be_added_or_removed(): + nodes = _apply(ROUND_ONE, {"op": "update_node", "id": "review_team", "config": {"agents": [ + {"name": "撰稿员", "remove": True}, + {"name": "核对员", "description": "复核口径", "tools": ["db_schema__shop"]}, + {"name": "取数员", "tools": None}, + ]}}) + agents = nodes["review_team"]["data"]["config"]["agents"] + assert [a["name"] for a in agents] == ["取数员", "核对员"], "原有成员保持原来的顺序,新成员接在后面" + assert "tools" not in agents[0], "成员字段写 null 才删" + assert agents[0]["system"] == "只用 SQL 取数" + assert agents[1]["tools"] == ["db_schema__shop"] + + +def test_merging_builds_a_new_config_instead_of_editing_the_old_one(): + """合并出来的 config 是新的:改之前的那份还要拿去比对工具变化。""" + from app.api.copilot import _apply_op + + base = copy.deepcopy(ROUND_ONE) + nodes = {n["id"]: n for n in base["nodes"]} + old = nodes["review_team"]["data"]["config"] + snapshot = copy.deepcopy(old) + _apply_op(nodes, [], {"op": "update_node", "id": "review_team", + "config": {"goal": "换个目标", "agents": [{"name": "取数员", "tools": []}]}}) + assert old == snapshot, "旧 config(连同成员)被原地改掉了" + assert nodes["review_team"]["data"]["config"]["agents"][0]["tools"] == [] + + +def test_label_only_updates_leave_the_config_alone(): + nodes = _apply(ROUND_ONE, {"op": "update_node", "id": "query_data", "label": "取数"}) + assert nodes["query_data"]["data"]["label"] == "取数" + assert nodes["query_data"]["data"]["config"]["tools"] == QUERY_TOOLS + + +# -------------------------------------------------------------------------- +# 3. 协议文案和合并语义一致 +# -------------------------------------------------------------------------- + + +def test_the_protocol_no_longer_tells_the_model_to_replace_the_whole_config(): + from app.api.copilot import _STREAM_PROTOCOL, _repair_request + + assert "整体替换" not in _STREAM_PROTOCOL + assert "只写要改的" in _STREAM_PROTOCOL + assert "null" in _STREAM_PROTOCOL, "删键的写法要告诉模型" + assert "name" in _STREAM_PROTOCOL and "remove" in _STREAM_PROTOCOL, "成员按 name 合并、怎么删成员要写清" + + nodes = {n["id"]: copy.deepcopy(n) for n in ROUND_ONE["nodes"]} + + issue = {"level": "error", "node_id": "query_data", "message": "缺了点什么", "field": None} + text = _repair_request(nodes, list(ROUND_ONE["edges"]), [issue]) + assert "整体替换" not in text and "完整的 config" not in text + assert "只写要改的" in text + + +# -------------------------------------------------------------------------- +# 4. 工具集合变小了:自查报 warning,回执列出变化 +# -------------------------------------------------------------------------- + + +async def test_dropping_tools_without_being_asked_is_called_out(client, monkeypatch): + ops = [ + {"op": "plan", "summary": "改提示词"}, + {"op": "update_node", "id": "query_data", "config": {"prompt": "简短一点", "tools": []}}, + {"op": "update_node", "id": "review_team", + "config": {"agents": [{"name": "取数员", "tools": []}]}}, + {"op": "done", "explanation": "改短了"}, + ] + events = await _stream(client, monkeypatch, _Scripted(ops), { + "instruction": "提示词写短一点", "base_graph": copy.deepcopy(ROUND_ONE), + }) + final = events[-1] + assert final["op"] == "final", final + + changes = {(c["node_id"], c.get("member")): c for c in final["tool_changes"]} + agent = changes[("query_data", None)] + assert agent["before"] == QUERY_TOOLS and agent["after"] == [] + assert agent["removed"] == QUERY_TOOLS and agent["added"] == [] + assert agent["label"] == "数据查询" + member = changes[("review_team", "取数员")] + assert member["before"] == ["db_query__shop"] and member["after"] == [] + + dropped = [i for i in final["issues"] if i.get("code") == "tools_dropped"] + assert {i["node_id"] for i in dropped} == {"query_data", "review_team"} + assert all(i["level"] == "warning" for i in dropped) + text = next(i["message"] for i in dropped if i["node_id"] == "query_data") + assert "数据查询" in text and "db_query__shop、db_schema__shop" in text and "变成了空" in text + text = next(i["message"] for i in dropped if i["node_id"] == "review_team") + assert "取数员" in text and "db_query__shop" in text + + # 自查那一步也要说出来,不能只埋在 final 里 + check = next(e for e in events if e.get("op") == "check" and e["status"] in ("passed", "failed")) + assert {w["node_id"] for w in check["warnings"]} == {"query_data", "review_team"} + + +async def test_dropping_tools_on_request_is_listed_but_not_warned(client, monkeypatch): + ops = [ + {"op": "update_node", "id": "query_data", "config": {"tools": ["db_query__shop"]}}, + {"op": "done", "explanation": "去掉了查表结构"}, + ] + events = await _stream(client, monkeypatch, _Scripted(ops), { + "instruction": "数据查询节点去掉查表结构的工具", "base_graph": copy.deepcopy(ROUND_ONE), + }) + final = events[-1] + assert [(c["node_id"], c["removed"]) for c in final["tool_changes"]] == [ + ("query_data", ["db_schema__shop"])] + assert not [i for i in final["issues"] if i.get("code") == "tools_dropped"] + + +async def test_swapping_one_tool_for_another_is_listed_but_not_warned(client, monkeypatch): + ops = [ + {"op": "update_node", "id": "query_data", + "config": {"tools": ["db_query__sales", "db_schema__sales"]}}, + {"op": "done", "explanation": "换到 sales"}, + ] + events = await _stream(client, monkeypatch, _Scripted(ops), { + "instruction": "改成查 sales 库", "base_graph": copy.deepcopy(ROUND_ONE), + }) + final = events[-1] + change = final["tool_changes"][0] + assert change["added"] == ["db_query__sales", "db_schema__sales"] + assert change["removed"] == QUERY_TOOLS + assert not [i for i in final["issues"] if i.get("code") == "tools_dropped"] + + +async def test_a_fresh_build_has_no_tool_changes(client, monkeypatch): + ops = [ + {"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入", "config": {}}}, + {"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果", "config": {}}}, + {"op": "add_edge", "edge": {"source": "start", "target": "out"}}, + {"op": "done", "explanation": "空壳"}, + ] + events = await _stream(client, monkeypatch, _Scripted(ops), {"instruction": "随便搭一个"}) + assert events[-1]["tool_changes"] == [] + + +async def test_the_one_shot_generate_reports_tool_changes_too(client, monkeypatch): + """非流式的 /generate 交回的是整张新图:照样比对、照样提醒。""" + import app.api.copilot as copilot + + rewritten = copy.deepcopy(ROUND_ONE) + del _config(rewritten, "query_data")["tools"] + raw = { + "explanation": "改了提示词", + "nodes": [{"id": n["id"], "type": n["type"], "label": n["data"]["label"], + "config": n["data"]["config"]} for n in rewritten["nodes"]], + "edges": rewritten["edges"], + } + + class _OneShot: + def with_structured_output(self, *_a, **_k): + return self + + async def ainvoke(self, _messages): + return raw + + async def _model(*_a, **_k): + return _OneShot(), "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + r = await client.post("/api/copilot/generate", json={ + "instruction": "提示词写短一点", "base_graph": copy.deepcopy(ROUND_ONE), + }) + assert r.status_code == 200, r.text + body = r.json() + assert [(c["node_id"], c["after"]) for c in body["tool_changes"]] == [("query_data", [])] + assert [i["node_id"] for i in body["issues"] if i.get("code") == "tools_dropped"] == ["query_data"] + + +# -------------------------------------------------------------------------- +# 5. 「指令里让没让删工具」得看删的是不是工具,不能见词就算 +# -------------------------------------------------------------------------- +# +# 以前只要指令里某处有「不要 / 不用 / 取消」、另一处有「工具」就当成让删了。 +# 可出事之后用户最可能的追问恰恰是「这次要真的用工具查库,不要编造数字」—— +# 两个词都在,警告被关掉,没工具的图照样自动运行。 + + +def _dropped(instruction: str, *, query: list[str], fetcher: list[str] | None = None) -> set[str]: + """把数据查询的工具改成 query(取数员改成 fetcher),看哪些节点会报 tools_dropped。""" + from app.api.copilot import dropped_tool_warnings, tool_changes + + after = copy.deepcopy(ROUND_ONE) + _config(after, "query_data")["tools"] = query + if fetcher is not None: + _member(after, "review_team", "取数员")["tools"] = fetcher + warnings = dropped_tool_warnings(tool_changes(ROUND_ONE["nodes"], after["nodes"]), instruction) + assert all(w["code"] == "tools_dropped" and w["level"] == "warning" for w in warnings) + return {w["node_id"] for w in warnings} + + +@pytest.mark.parametrize("instruction", [ + "把 system 改成英文,不用改工具", + "这次要真的用工具查库,不要编造数字", + "数据查询要真的用工具查库,不要编造数字", + "取消复核团队,工具保留", + "别用假设的方式,必须调用工具", + "别用假设的方式调用工具", + "不要把工具去掉,提示词短一点", + "千万别删除工具", + "工具不用动,按月份拆", + "去掉提示词里的废话然后让它调用工具", + "don't remove any tools, just make the prompt shorter", + "answer without making up numbers, use the tools", +]) +def test_asking_to_keep_or_use_tools_still_warns_when_they_vanish(instruction): + assert _dropped(instruction, query=[], fetcher=[]) == {"query_data", "review_team"} + + +@pytest.mark.parametrize("instruction", [ + "去掉查表结构的工具", + "数据查询节点去掉查表结构的工具", + "不要用 db_schema__shop", + "把查表结构的工具去掉", + "db_schema__shop 不要了", + "查表结构那个工具也删了吧", + "remove the schema tool", + "drop db_schema__shop from the query node", +]) +def test_asking_to_drop_a_tool_is_not_warned(instruction): + assert _dropped(instruction, query=["db_query__shop"]) == set() + + +def test_naming_one_tool_does_not_excuse_losing_the_others(): + """点名删 db_schema__shop,模型却把 db_query__shop 也删了:少掉的那个没人要求。""" + assert _dropped("去掉 db_schema__shop", query=[]) == {"query_data"} + assert _dropped("去掉 db_schema__shop", query=["db_query__shop"]) == set() + + +def test_keeping_only_some_tools_counts_as_asking_to_drop_the_rest(): + assert _dropped("数据查询只保留 db_query__shop", query=["db_query__shop"]) == set() + # 「只保留」的那个也没了,就不是用户要的 + assert _dropped("数据查询只保留 db_query__shop", query=[]) == {"query_data"} + assert _dropped("只保留查库的工具", query=["db_query__shop"]) == set() + assert _dropped("只保留查库的工具", query=[]) == {"query_data"} + + +def test_deleting_some_other_field_is_not_asking_to_drop_tools(): + assert _dropped("去掉 assign_to", query=[]) == {"query_data"} + assert _dropped("删除取数员的 description", query=[], fetcher=[]) == {"query_data", "review_team"} + + +def _resend_everything_without_tools() -> list[dict]: + """问数据页的追问:模型把整张图用 add_node 重发了一遍,每个节点都没写 tools。""" + ops: list[dict] = [{"op": "plan", "summary": "重搭一遍"}] + for n in ROUND_ONE["nodes"]: + cfg = copy.deepcopy(n["data"]["config"]) + cfg.pop("tools", None) + for member in cfg.get("agents") or []: + member.pop("tools", None) + ops.append({"op": "add_node", "node": {"id": n["id"], "type": n["type"], + "label": n["data"]["label"], "config": cfg}}) + ops += [{"op": "add_edge", "edge": e} for e in ROUND_ONE["edges"]] + ops.append({"op": "done", "explanation": "按要求重搭", "run": True}) + return ops + + +async def test_a_chat_follow_up_that_loses_every_tool_is_called_out(client, monkeypatch): + conv_id = await _chat_with_round_one(client) + events = await _stream(client, monkeypatch, _Scripted(_resend_everything_without_tools()), { + "instruction": "这次要真的用工具查库,不要编造数字", "intent": "answer", + "conversation_id": conv_id, + }) + final = events[-1] + assert final["op"] == "final", final + lost = {(c["node_id"], c["member"]) for c in final["tool_changes"]} + assert lost == {("query_data", None), ("review_team", "取数员")} + + dropped = [i for i in final["issues"] if i.get("code") == "tools_dropped"] + assert {i["node_id"] for i in dropped} == {"query_data", "review_team"} + check = next(e for e in events if e.get("op") == "check" and e["status"] in ("passed", "failed")) + assert {w["node_id"] for w in check["warnings"]} == {"query_data", "review_team"} diff --git a/backend/tests/test_copilot_scope.py b/backend/tests/test_copilot_scope.py new file mode 100644 index 00000000..d0ed604e --- /dev/null +++ b/backend/tests/test_copilot_scope.py @@ -0,0 +1,197 @@ +"""问数据时可以把这一轮限定在某几个数据源上。 + +问数据页的数据源标签要能「点一下,这次只查这个库」。以前助手每一轮都看得到 +全部数据源,追问时还会沿用上一轮接的那个库——用户明明想换一个库问,得到的 +还是旧库上的答案,而且说不清为什么。 + +限定之后:给模型看的工具和结构只剩这几个库;图里用了范围外的库,按自查的 +规则交回去改,改不掉就不自动运行。 +""" +from __future__ import annotations + +import json +import sqlite3 + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.base import SessionLocal +from app.db.models import DataSource +from app.main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +async def sources(tmp_path): + """两个库:scope_shop(orders)和 scope_sales(sales_daily)。""" + made: dict[str, str] = {} + async with SessionLocal() as session: + for name, table in (("scope_shop", "orders"), ("scope_sales", "sales_daily")): + path = tmp_path / f"{name}.db" + db = sqlite3.connect(path) + db.execute(f"CREATE TABLE {table} (id INTEGER PRIMARY KEY, amount REAL)") + db.commit() + db.close() + row = DataSource(name=name, kind="sqlite", database=str(path), description=f"{table} 库", + schema_cache={"tables": {table: {"qualified": table, "columns": [ + {"name": "id", "type": "INTEGER"}, {"name": "amount", "type": "REAL"}]}}}) + session.add(row) + await session.flush() + made[name] = row.id + await session.commit() + yield made + async with SessionLocal() as session: + for source_id in made.values(): + row = await session.get(DataSource, source_id) + if row: + await session.delete(row) + await session.commit() + + +class _Scripted: + def __init__(self, first: list[dict], fix: list[dict] | None = None) -> None: + self.first = [json.dumps(o, ensure_ascii=False) for o in first] + self.fix = [json.dumps(o, ensure_ascii=False) for o in (fix or [{"op": "done"}])] + self.systems: list[str] = [] + self.humans: list[str] = [] + + async def astream(self, messages): + from langchain_core.messages import AIMessageChunk + + self.systems.append(next(t for r, t in messages if r == "system")) + human = next(t for r, t in messages if r == "human") + self.humans.append(human) + for line in (self.fix if "上线前自查" in human else self.first): + yield AIMessageChunk(content=line + "\n") + + +def _graph_using(tool: str) -> list[dict]: + return [ + {"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入", "config": {}}}, + {"op": "add_node", "node": {"id": "ask", "type": "agent", "label": "查库", "config": { + "prompt": "{{ input.question }}", "tools": [tool], "assign_to": "answer"}}}, + {"op": "add_edge", "edge": {"source": "start", "target": "ask"}}, + {"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果", "config": { + "fields": [{"name": "answer", "value": "{{ vars.answer }}"}]}}}, + {"op": "add_edge", "edge": {"source": "ask", "target": "out"}}, + {"op": "done", "explanation": "查一下", "run": True}, + ] + + +async def _stream(client, monkeypatch, model, body: dict) -> tuple[int, list[dict]]: + import app.api.copilot as copilot + + async def _model(*_a, **_k): + return model, "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + events: list[dict] = [] + async with client.stream("POST", "/api/copilot/generate-stream", json=body) as r: + if r.status_code != 200: + return r.status_code, [json.loads(await r.aread())] + async for line in r.aiter_lines(): + if line.startswith("data: "): + events.append(json.loads(line[6:])) + return 200, events + + +async def test_without_a_scope_the_model_sees_every_source(client, monkeypatch, sources): + model = _Scripted(_graph_using("db_query__scope_shop")) + await _stream(client, monkeypatch, model, {"instruction": "订单多少", "intent": "answer"}) + assert "db_query__scope_shop" in model.systems[0] + assert "db_query__scope_sales" in model.systems[0] + + +async def test_a_scope_narrows_what_the_model_sees(client, monkeypatch, sources): + model = _Scripted(_graph_using("db_query__scope_shop")) + status, events = await _stream(client, monkeypatch, model, { + "instruction": "订单多少", "intent": "answer", "datasource_ids": [sources["scope_shop"]], + }) + assert status == 200 + system = model.systems[0] + assert "db_query__scope_shop" in system and "orders" in system + assert "db_query__scope_sales" not in system and "sales_daily" not in system + assert "只查" in system and "scope_shop" in system, "限定要明说,不然模型会沿用上一轮的库" + final = events[-1] + assert final["op"] == "final" and final["autorun"] is True + assert not [i for i in final["issues"] if i.get("code") == "datasource_out_of_scope"] + + +async def test_a_scope_can_name_sources_too(client, monkeypatch, sources): + model = _Scripted(_graph_using("db_query__scope_sales")) + await _stream(client, monkeypatch, model, { + "instruction": "销售额", "intent": "answer", "datasource_ids": ["scope_sales"], + }) + assert "db_query__scope_sales" in model.systems[0] + assert "db_query__scope_shop" not in model.systems[0] + + +async def test_using_a_source_outside_the_scope_is_sent_back(client, monkeypatch, sources): + fix = [{"op": "update_node", "id": "ask", "config": {"tools": ["db_query__scope_shop"]}}, + {"op": "done"}] + model = _Scripted(_graph_using("db_query__scope_sales"), fix) + _, events = await _stream(client, monkeypatch, model, { + "instruction": "订单多少", "intent": "answer", "datasource_ids": [sources["scope_shop"]], + }) + checks = [e for e in events if e.get("op") == "check"] + assert [c["status"] for c in checks] == ["repairing", "passed"], checks + out_of_scope = [i for i in checks[0]["issues"] if "scope_sales" in i["message"]] + assert out_of_scope, checks[0] + assert out_of_scope[0]["node_id"] == "ask" + assert out_of_scope[0]["code"] == "datasource_out_of_scope", "和 final.issues 同一个 code" + final = events[-1] + ask = next(n for n in final["graph"]["nodes"] if n["id"] == "ask") + assert ask["data"]["config"]["tools"] == ["db_query__scope_shop"] + assert final["autorun"] is True + + +async def test_a_scope_that_is_never_honoured_is_not_run(client, monkeypatch, sources): + model = _Scripted(_graph_using("db_query__scope_sales")) + _, events = await _stream(client, monkeypatch, model, { + "instruction": "订单多少", "intent": "answer", "datasource_ids": [sources["scope_shop"]], + }) + final = events[-1] + assert final["autorun"] is False + out_of_scope = [i for i in final["issues"] if i.get("code") == "datasource_out_of_scope"] + assert [i["node_id"] for i in out_of_scope] == ["ask"] + assert "scope_sales" in out_of_scope[0]["message"] and "scope_shop" in out_of_scope[0]["message"] + + +async def test_a_scope_of_unknown_sources_is_refused_up_front(client, monkeypatch, sources): + status, body = await _stream(client, monkeypatch, _Scripted([]), { + "instruction": "订单多少", "intent": "answer", "datasource_ids": ["no_such_source"], + }) + assert status == 400 + detail = body[0]["detail"] + assert "数据源" in detail and "no_such_source" not in detail.split(":")[0] + + +async def test_the_one_shot_generate_honours_the_scope(client, monkeypatch, sources): + import app.api.copilot as copilot + + seen: list = [] + + class _OneShot: + def with_structured_output(self, *_a, **_k): + return self + + async def ainvoke(self, messages): + seen.append(messages) + return {"nodes": [], "edges": []} + + async def _model(*_a, **_k): + return _OneShot(), "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + r = await client.post("/api/copilot/generate", json={ + "instruction": "订单多少", "datasource_ids": [sources["scope_shop"]], + }) + assert r.status_code == 200, r.text + system = next(t for role, t in seen[0] if role == "system") + assert "db_query__scope_shop" in system and "db_query__scope_sales" not in system diff --git a/backend/tests/test_copilot_selfcheck.py b/backend/tests/test_copilot_selfcheck.py index dca7d36b..6e56e193 100644 --- a/backend/tests/test_copilot_selfcheck.py +++ b/backend/tests/test_copilot_selfcheck.py @@ -100,7 +100,7 @@ async def test_a_broken_condition_is_sent_back_and_fixed_before_delivery(client, checks = [e for e in events if e["op"] == "check"] assert [c["status"] for c in checks] == ["repairing", "passed"], checks - assert any("len(x)" in i for i in checks[0]["issues"]), "交回去的问题里得带着能照改的提示" + assert any("len(x)" in i["message"] for i in checks[0]["issues"]), "交回去的问题里得带着能照改的提示" assert "len(x)" in model.calls[1], "自查请求没把问题原文交给模型" final = events[-1] @@ -138,3 +138,23 @@ async def test_template_braces_are_accepted_without_a_rework(client, monkeypatch final = events[-1] assert final["autorun"] is True assert any("是多余的" in i["message"] for i in final["issues"]) + + +async def test_check_issues_point_at_the_node_and_the_field(client, monkeypatch): + """自查的问题是对象,不是拼好的一行字:界面要靠 node_id 定位到卡片、靠 field + 落到检查器里具体的输入框。以前是「「lp」循环条件写错了」,节点 id 只能从字里抠。""" + model = _Scripted(_graph_ops("foo(vars.items)"), _fix_ops("bar(vars.items)")) + events = await _generate(client, monkeypatch, model) + + checks = [e for e in events if e["op"] == "check"] + assert [c["status"] for c in checks] == ["repairing", "repairing", "failed"], checks + for check in checks: + assert check["issues"], check + for issue in check["issues"]: + assert isinstance(issue, dict), f"自查问题还是一行字:{issue!r}" + assert issue["node_id"] == "lp" and issue["field"] == "condition", issue + assert issue["level"] == "error" + assert issue["message"] and not issue["message"].startswith("「"), \ + "节点另有 node_id,消息里不该再拼一遍" + # 交回模型的修正请求照旧是能读的一行一条,节点 id 在前 + assert "「lp」" in model.calls[1] diff --git a/backend/tests/test_custom_tool_draft.py b/backend/tests/test_custom_tool_draft.py new file mode 100644 index 00000000..55382603 --- /dev/null +++ b/backend/tests/test_custom_tool_draft.py @@ -0,0 +1,227 @@ +"""自定义工具没保存也能试跑;试跑和运行时走同一套参数校验;工具自己报的失败算失败。 + +以前编辑器里的「试跑」只能跑已保存的版本:新建时要先保存才能试,改了代码要先 +保存再试——而保存就意味着节点里立刻能用上一个还没试过的工具。 + +另外两件:试跑以前不按参数 schema 校验,运行时却校验,于是试跑通过的参数到了 +工作流里被拒;沙箱里代码报错、地址被拦时,结果照样标「成功」,再附一段报错 JSON。 +""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import func, select + +from app.db.base import SessionLocal +from app.db.models import CustomTool +from app.main import app +from app.sandbox.base import ExecResult + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +class _FakeSandbox: + def __init__(self, result: ExecResult) -> None: + self.result = result + self.codes: list[str] = [] + + async def run(self, code, **_kw): # noqa: ANN001, ANN003, ANN201 + self.codes.append(code) + return self.result + + +def _sandbox(monkeypatch, **result) -> _FakeSandbox: + import app.tools.custom as custom + + fake = _FakeSandbox(ExecResult(**{"ok": True, "exit_code": 0, "stdout": "", "stderr": "", **result})) + monkeypatch.setattr(custom, "sandbox_manager", fake) + return fake + + +async def _tool_count() -> int: + async with SessionLocal() as session: + return int((await session.execute(select(func.count(CustomTool.id)))).scalar_one()) + + +PARAMS = {"type": "object", "properties": {"store": {"type": "string"}, "days": {"type": "integer"}}, + "required": ["store"]} + + +async def test_an_unsaved_python_tool_can_be_tried(client, monkeypatch): + fake = _sandbox(monkeypatch, stdout='{"orders": 42}\n') + before = await _tool_count() + r = await client.post("/api/custom-tools/test", json={ + "kind": "python", "parameters": PARAMS, + "config": {"code": "print(json.dumps({'orders': 42}))"}, + "args": {"store": "north", "days": "7"}, + }) + body = r.json() + assert body["ok"] is True, body + assert body["result"] == {"orders": 42} + assert body["duration_ms"] >= 0 + # 参数和运行时一样先过 schema:字符串 "7" 被纠正成整数 7(代码前导里是转义过的 JSON) + assert '\\"days\\": 7' in fake.codes[0] and '\\"store\\": \\"north\\"' in fake.codes[0] + assert await _tool_count() == before, "试跑不该落库" + + +async def test_arguments_that_the_schema_rejects_fail_like_they_would_at_runtime(client, monkeypatch): + _sandbox(monkeypatch, stdout="1") + r = await client.post("/api/custom-tools/test", json={ + "kind": "python", "parameters": PARAMS, "config": {"code": "print(1)"}, "args": {"days": 3}, + }) + body = r.json() + assert body["ok"] is False + assert "store" in body["error"], "缺了哪个参数得说出来" + + +async def test_code_that_crashes_is_reported_as_a_failure(client, monkeypatch): + _sandbox(monkeypatch, ok=False, exit_code=1, + stderr="Traceback (most recent call last):\n ...\nNameError: name 'x' is not defined") + r = await client.post("/api/custom-tools/test", json={ + "kind": "python", "config": {"code": "print(x)"}, "args": {}, + }) + body = r.json() + assert body["ok"] is False, "沙箱里报错了,不能标成功" + assert "退出码 1" in body["error"] + assert "NameError" in body["detail"], "原始报错要留着,用户得照着改代码" + assert body["hint"] + + +async def test_an_http_tool_to_a_private_address_is_blocked_and_says_so(client): + r = await client.post("/api/custom-tools/test", json={ + "kind": "http", "config": {"url": "http://127.0.0.1:9/orders", "method": "GET"}, "args": {}, + }) + body = r.json() + assert body["ok"] is False + assert "拦截" in body["error"] or "内网" in body["error"], body + + +async def test_an_http_tool_renders_its_template_with_the_args(client, monkeypatch): + import app.tools.custom as custom + + seen: dict = {} + + async def fake_request(method, url, **kw): + seen.update(method=method, url=url, **kw) + return {"status": 200, "body": '{"count": 3}'} + + monkeypatch.setattr(custom, "safe_request", fake_request) + r = await client.post("/api/custom-tools/test", json={ + "kind": "http", "parameters": PARAMS, + "config": {"url": "https://api.example.com/orders?store={{ store }}", "method": "GET", + "parse_json": True}, + "args": {"store": "north"}, + }) + body = r.json() + assert body["ok"] is True, body + assert seen["url"] == "https://api.example.com/orders?store=north" + assert body["result"] == {"status": 200, "data": {"count": 3}} + + +@pytest.mark.parametrize("draft, words", [ + ({"kind": "http", "config": {}}, "接口地址"), + ({"kind": "python", "config": {"code": " "}}, "代码"), + ({"kind": "graphql", "config": {}}, "graphql"), +]) +async def test_an_incomplete_draft_says_what_is_missing(client, draft, words): + body = (await client.post("/api/custom-tools/test", json={**draft, "args": {}})).json() + assert body["ok"] is False and words in body["error"], body + + +async def test_a_misspelt_argument_name_is_run_but_not_kept_quiet(client, monkeypatch): + """参数名写错、只有一个候选时,运行时替它改名跑通,但一定会说出来(fix_note)。 + + 试跑以前把这句说明丢了:{"cuont": 3} 照样 ok,结果里也看不出哪里不对, + 用户照着这份参数去配工作流,错的名字就一直留着。 + """ + fake = _sandbox(monkeypatch, stdout='{"count": 3}') + r = await client.post("/api/custom-tools/test", json={ + "kind": "python", "config": {"code": "print(json.dumps(args))"}, + "parameters": {"type": "object", "properties": {"count": {"type": "integer"}}, + "required": ["count"]}, + "args": {"cuont": 3}, + }) + body = r.json() + assert body["ok"] is True, body + assert '\\"count\\": 3' in fake.codes[0], "改名之后照样执行" + assert "cuont" in body["note"] and "count" in body["note"], body + + # 名字写对了就不多嘴 + r = await client.post("/api/custom-tools/test", json={ + "kind": "python", "config": {"code": "print(1)"}, + "parameters": {"type": "object", "properties": {"count": {"type": "integer"}}}, + "args": {"count": 3}, + }) + assert "note" not in r.json(), r.json() + + +async def test_the_tool_library_run_also_says_when_it_renamed_an_argument(client): + """工具库的「执行」和试跑是同一种形状,纠正说明也一样要带上。""" + r = await client.post("/api/tools/memory_recall/run", json={"args": {"query": "口径", "limt": 3}}) + body = r.json() + assert body["ok"] is True, body + assert "limt" in body["note"] and "limit" in body["note"], body + + +@pytest.mark.parametrize("parameters, words", [ + ({"properties": "bad"}, "properties"), + ({"properties": {"n": "int"}}, "n"), + ({"properties": {"n": {"type": "int"}}}, "int"), + ({"properties": {"n": {"type": ["integer", "null"]}}}, "n"), + ({"properties": {"n": {"type": "integer"}}, "required": "n"}, "required"), +]) +async def test_a_malformed_parameter_schema_is_the_users_to_fix(client, monkeypatch, parameters, words): + """参数定义是用户手写的 JSON。写坏了是配置问题,不能说成「后端内部出错了」。""" + fake = _sandbox(monkeypatch, stdout="1") + body = (await client.post("/api/custom-tools/test", json={ + "kind": "python", "config": {"code": "print(1)"}, "parameters": parameters, "args": {}, + })).json() + assert body["ok"] is False, body + assert "参数定义" in body["error"] and words in body["error"], body + assert "内部" not in body["error"] and "维护者" not in (body["hint"] or ""), body + assert not fake.codes, "参数定义都不对,就别去沙箱里跑了" + + +async def test_the_saved_tool_trial_follows_the_same_rules(client, monkeypatch): + _sandbox(monkeypatch, ok=False, exit_code=2, stderr="boom") + r = await client.post("/api/custom-tools", json={ + "name": "trial_saved", "kind": "python", "parameters": PARAMS, "config": {"code": "print(1)"}, + }) + tool = r.json() + try: + body = (await client.post(f"/api/custom-tools/{tool['id']}/test", + json={"args": {"store": "north"}})).json() + assert body["ok"] is False and "退出码 2" in body["error"] + body = (await client.post(f"/api/custom-tools/{tool['id']}/test", json={"args": {}})).json() + assert body["ok"] is False and "store" in body["error"] + finally: + await client.delete(f"/api/custom-tools/{tool['id']}") + + +async def test_at_runtime_a_crash_still_reaches_the_model_as_an_error_payload(monkeypatch): + """运行时的行为不变:失败以 {"error": …} 的 JSON 交给模型,由它决定怎么办。""" + import json + + from app.tools.custom import build_custom_tools + from app.tools.registry import ToolContext + + _sandbox(monkeypatch, ok=False, exit_code=1, stderr="boom") + async with SessionLocal() as session: + session.add(CustomTool(name="trial_runtime", kind="python", parameters={}, + config={"code": "raise SystemExit(1)"})) + await session.commit() + try: + tools = await build_custom_tools(["trial_runtime"], ToolContext(run_id="r", node_id="n"), + session) + out = json.loads(await tools[0].coroutine(input="")) + assert out["exit_code"] == 1 and out["error"] + finally: + row = (await session.execute( + select(CustomTool).where(CustomTool.name == "trial_runtime"))).scalar_one() + await session.delete(row) + await session.commit() diff --git a/backend/tests/test_custom_tool_schema.py b/backend/tests/test_custom_tool_schema.py new file mode 100644 index 00000000..cf878e91 --- /dev/null +++ b/backend/tests/test_custom_tool_schema.py @@ -0,0 +1,307 @@ +"""自定义工具的参数定义写坏了:保存时就拒掉;库里已经存着的坏工具,运行时说清是哪个、坏在哪。 + +参数定义是用户在编辑器里手写的 JSON Schema。以前保存照单全收,写成 +{"city": "string"} 或 {"type": "int"} 都存得进去;到了工作流绑定这个工具的那一刻, +schema_to_model 在 .get 上炸掉,运行记录里只剩一句「执行出错:'str' object has no +attribute 'get'」——既不知道是哪个工具,也不知道该去哪里改。 +""" +from __future__ import annotations + +import asyncio +import uuid + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import func, select + +from app.db.base import SessionLocal +from app.db.models import CustomTool, Run, RunEvent +from app.engine.runner import run_manager +from app.main import app + +GOOD = {"type": "object", "properties": {"store": {"type": "string", "description": "门店"}}, + "required": ["store"]} + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +def _name() -> str: + return f"lookup_{uuid.uuid4().hex[:6]}" + + +async def _count() -> int: + async with SessionLocal() as session: + return int((await session.execute(select(func.count(CustomTool.id)))).scalar_one()) + + +# -------------------------------------------------------------------------- +# 保存时拒掉 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("parameters, words", [ + ({"type": "object", "properties": {"city": "string"}}, ["city", '{"type": "string"}']), + ({"type": "object", "properties": {"n": {"type": "int"}}}, ["n", "「int」", "integer"]), + ({"type": "object", "properties": {"n": {"type": "integer"}}, "required": "n"}, ["required"]), + ({"type": "object", "properties": ["city"]}, ["properties"]), + ({"type": "object", "properties": {"_id": {"type": "string"}}}, ["_id", "下划线"]), + ({"type": "object", "properties": {"model_config": {"type": "string"}}}, ["model_config"]), + ({"type": "object", "properties": {"": {"type": "string"}}}, ["参数名"]), +]) +async def test_a_malformed_schema_is_refused_with_words(client, parameters, words): + before = await _count() + r = await client.post("/api/custom-tools", json={ + "name": _name(), "kind": "http", "parameters": parameters, + "config": {"url": "https://example.com/api"}}) + assert r.status_code == 422, r.text + detail = r.json()["detail"] + assert isinstance(detail, str), f"detail 要是一句话,不是 pydantic 的错误列表:{detail!r}" + for word in words: + assert word in detail, (word, detail) + assert "Error" not in detail and "Traceback" not in detail + assert await _count() == before, "写坏的工具不该落库" + + +@pytest.mark.parametrize("spec, fix, not_this", [ + # 照抄报错里的写法就能改好:写的是 "string",就别让人改成 integer + ("string", '{"type": "string"}', "integer"), + ("int", '{"type": "integer"}', None), + ("Number", '{"type": "number"}', None), + ("bool", '{"type": "boolean"}', None), + (5, '{"type": "string"}', None), +]) +def test_the_example_in_the_message_is_the_type_they_meant(spec, fix, not_this): + from app.tools.custom import schema_problem + + problem = schema_problem({"type": "object", "properties": {"store": spec}}) + assert problem and fix in problem, problem + if not_this: + assert not_this not in problem, problem + + +@pytest.mark.parametrize("kind, guess", [("int", "integer"), ("String", "string"), ("float", "number"), + ("dict", "object")]) +def test_a_near_miss_type_gets_a_suggestion(kind, guess): + from app.tools.custom import schema_problem + + problem = schema_problem({"type": "object", "properties": {"n": {"type": kind}}}) + assert problem and f"是不是想写 {guess}" in problem, problem + + +def test_an_unrecognisable_type_gets_no_guess(): + from app.tools.custom import schema_problem + + problem = schema_problem({"type": "object", "properties": {"n": {"type": "timestamp"}}}) + assert problem and "「timestamp」" in problem and "是不是想写" not in problem, problem + + +async def test_parameters_that_are_not_an_object_are_refused_in_words_too(client): + r = await client.post("/api/custom-tools", json={ + "name": _name(), "kind": "http", "parameters": ["city"], "config": {}}) + assert r.status_code == 422 + assert r.json()["detail"] == "参数定义要是一个 JSON 对象" + + +async def test_editing_a_tool_into_a_broken_schema_is_refused_and_nothing_changes(client): + r = await client.post("/api/custom-tools", json={ + "name": _name(), "kind": "http", "parameters": GOOD, + "config": {"url": "https://example.com/api"}}) + assert r.status_code == 201, r.text + tool = r.json() + try: + broken = {**tool, "parameters": {"type": "object", "properties": {"store": "string"}}} + r = await client.patch(f"/api/custom-tools/{tool['id']}", json=broken) + assert r.status_code == 422, r.text + assert "store" in r.json()["detail"] + async with SessionLocal() as session: + row = await session.get(CustomTool, tool["id"]) + assert row.parameters == GOOD, "被拒的修改不能落一半" + finally: + await client.delete(f"/api/custom-tools/{tool['id']}") + + +async def test_a_good_schema_still_saves(client): + r = await client.post("/api/custom-tools", json={ + "name": _name(), "kind": "http", "parameters": GOOD, + "config": {"url": "https://example.com/api"}}) + assert r.status_code == 201, r.text + assert r.json()["problem"] is None + await client.delete(f"/api/custom-tools/{r.json()['id']}") + + +@pytest.mark.filterwarnings("ignore:Field name .* shadows an attribute") +def test_every_schema_the_check_lets_through_really_builds(): + """schema_problem 放行的,schema_to_model 一定建得出来:它是两者之间唯一的关卡。""" + from app.tools.custom import schema_problem, schema_to_model + + for name in ("store", "城市", "my-field", "json", "copy", "model_name"): + schema = {"type": "object", "properties": {name: {"type": "string"}}} + assert schema_problem(schema) is None, name + schema_to_model("t", schema).model_json_schema() + + +# -------------------------------------------------------------------------- +# 库里已经存着的坏工具 +# -------------------------------------------------------------------------- + +BROKEN = {"type": "object", "properties": {"store": "string"}} + + +@pytest.fixture +async def broken_tool(): + """绕过接口直接写库:模拟这次改动之前就存进去的坏工具。""" + name = _name() + async with SessionLocal() as session: + row = CustomTool(name=name, kind="http", parameters=BROKEN, + config={"url": "https://example.com/api"}, description="查门店") + session.add(row) + await session.commit() + tool_id = row.id + yield name + async with SessionLocal() as session: + row = await session.get(CustomTool, tool_id) + if row: + await session.delete(row) + await session.commit() + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), + "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def _finish(graph) -> tuple[Run, list[RunEvent]]: + run = await run_manager.start(graph=graph, input_payload={"question": "北区门店"}) + for _ in range(300): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed", "cancelled", "interrupted"): + break + async with SessionLocal() as session: + events = list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq) + )).scalars()) + return row, events + + +def _clear(text: str | None, name: str) -> None: + text = text or "" + assert name in text, f"没说是哪个工具:{text}" + assert "参数定义" in text and "store" in text, f"没说坏在哪:{text}" + assert "工具" in text and "改" in text, f"没说去哪里改:{text}" + for noise in ("执行出错", "内部", "attribute", "object has no"): + assert noise not in text, f"还是笼统的内部错误:{text}" + + +async def test_an_agent_bound_to_a_broken_tool_fails_saying_which_and_why(engine_up, broken_tool): + row, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("ask", "agent", prompt="查一下门店", tools=[broken_tool], max_steps=2), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.ask.text }}"}]), + )) + assert row.status == "failed" + assert row.error_node_id == "ask" + _clear(row.error, broken_tool) + failed = next(e.data for e in events if e.type == "node.failed") + _clear(failed["error"], broken_tool) + + +async def test_a_tool_node_bound_to_a_broken_tool_fails_saying_which_and_why(engine_up, broken_tool): + row, _ = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("call", "tool", tool=broken_tool, args={"store": "north"}, approval="never"), + node("out", "output", fields=[]), + )) + assert row.status == "failed" + assert row.error_node_id == "call" + _clear(row.error, broken_tool) + + +async def test_a_team_member_with_a_broken_tool_fails_that_step_with_the_reason( + engine_up, broken_tool, monkeypatch, +): + import json + + from langchain_core.messages import AIMessage + + from app.providers import mock_model + + routed: list[str] = [] + + def _decide(self, messages): + if self.response_format: # 调度者:派一次,看到结果就收工 + plan = ({"assignments": [{"agent": "查数员", "instruction": "查门店"}]} if not routed + else {"assignments": [], "done": True, "reason": "查数员的工具坏了,先收工"}) + routed.append("x") + return AIMessage(content=json.dumps(plan, ensure_ascii=False)) + return AIMessage(content="北区 3 家") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + _, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("team", "supervisor", goal="查门店", max_rounds=2, approval="never", agents=[ + {"name": "查数员", "system": "你负责查", "tools": [broken_tool], "max_steps": 2}]), + node("out", "output", fields=[]), + )) + step = next(e.data for e in events if e.type == "agent.step.end") + assert step.get("failed") is True, step + _clear(step.get("error"), broken_tool) + + +async def test_the_tool_library_marks_a_stored_broken_tool(client, broken_tool): + listed = next(t for t in (await client.get("/api/custom-tools")).json() if t["name"] == broken_tool) + assert listed["problem"] and "store" in listed["problem"] + item = next(t for t in (await client.get("/api/tools")).json() if t["id"] == broken_tool) + assert item["problem"] == listed["problem"] + good = [t for t in (await client.get("/api/tools")).json() if t["source"] == "builtin"] + assert good and all("problem" not in t for t in good), "内置工具不用带这个字段" + + +async def test_running_a_broken_tool_from_the_library_says_why(client, broken_tool): + r = await client.post(f"/api/tools/{broken_tool}/run", json={"args": {"store": "north"}, + "confirm": True}) + body = r.json() + assert body["ok"] is False + _clear(body["error"], broken_tool) + + +async def test_a_broken_tool_does_not_take_its_neighbours_down(broken_tool): + """同一个节点绑了好几个工具时,报错只点坏的那一个。""" + from app.tools.registry import ToolBuildError, ToolContext, build_tools + + good = _name() + async with SessionLocal() as session: + session.add(CustomTool(name=good, kind="http", parameters=GOOD, + config={"url": "https://example.com/api"})) + await session.commit() + try: + async with SessionLocal() as session: + ctx = ToolContext(run_id="t", node_id="n") + built = await build_tools([good], ctx, session=session) + assert [t.name for t in built] == [good] + with pytest.raises(ToolBuildError) as caught: + await build_tools([good, broken_tool], ctx, session=session) + assert broken_tool in str(caught.value) and good not in str(caught.value) + finally: + async with SessionLocal() as session: + row = (await session.execute(select(CustomTool).where(CustomTool.name == good))).scalar_one() + await session.delete(row) + await session.commit() diff --git a/backend/tests/test_datasource_preview.py b/backend/tests/test_datasource_preview.py new file mode 100644 index 00000000..7dcae041 --- /dev/null +++ b/backend/tests/test_datasource_preview.py @@ -0,0 +1,136 @@ +"""换个 schema 看看,不擅自改缓存;表结构给结构化的列;卡片知道缓存是按哪个 schema 探的。 + +「换个 schema 看看」以前一探就把缓存换成那个 schema 的结构——哪怕用户随后选了 +「只看看,不改」,助手此刻看到的已经是另一套表了。前端只好再探一次配置里的 +schema 把缓存换回来,而那一次要是失败,缓存和配置就对不上,只剩一条会消失的 toast。 + +表结构以前只有一段给模型看的文本,前端照着文本格式去解析列,格式一改就全乱。 +""" +from __future__ import annotations + +import sqlite3 + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +async def source(client, tmp_path): + path = tmp_path / "preview.db" + db = sqlite3.connect(path) + db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, store TEXT NOT NULL, amount REAL)") + db.execute("CREATE TABLE sales_daily (day TEXT, total REAL)") + db.execute("CREATE VIEW v_big_orders AS SELECT * FROM orders WHERE amount > 100") + db.commit() + db.close() + r = await client.post("/api/datasources", json={"name": "preview_src", "kind": "sqlite", + "database": str(path)}) + assert r.status_code == 201, r.text + yield r.json() + await client.delete(f"/api/datasources/{r.json()['id']}") + + +async def _cache(source_id: str) -> dict: + from app.db.base import SessionLocal + from app.db.models import DataSource + + async with SessionLocal() as session: + row = await session.get(DataSource, source_id) + return {"cache": dict(row.schema_cache or {}), "synced": row.schema_synced_at} + + +# -------------------------------------------------------------------------- +# REQ-7:缓存是按哪个 schema 探的 +# -------------------------------------------------------------------------- + + +async def test_cached_schema_says_which_schema_the_cache_came_from(client, source): + assert source["cached_schema"] is None, "没探过就是没有缓存" + r = await client.post(f"/api/datasources/{source['id']}/introspect") + assert r.json()["cached_schema"] == "", "按默认 schema 探的写空串,和「没探过」分开" + r = await client.post(f"/api/datasources/{source['id']}/introspect", params={"schema": "main"}) + assert r.json()["cached_schema"] == "main" + listed = next(s for s in (await client.get("/api/datasources")).json() if s["id"] == source["id"]) + assert listed["cached_schema"] == "main" + + +# -------------------------------------------------------------------------- +# REQ-6 ①:只看看,不改缓存 +# -------------------------------------------------------------------------- + + +async def test_a_dry_run_shows_what_is_there_without_touching_the_cache(client, source): + await client.post(f"/api/datasources/{source['id']}/introspect") + before = await _cache(source["id"]) + + r = await client.post(f"/api/datasources/{source['id']}/introspect", + params={"schema": "main", "dry_run": "true"}) + assert r.status_code == 200, r.text + preview = r.json() + assert preview["dry_run"] is True + assert preview["schema"] == "main" + assert preview["table_count"] == 3 + assert set(preview["tables"]) == {"main.orders", "main.sales_daily", "main.v_big_orders"} + assert preview["schema_error"] == "" + + after = await _cache(source["id"]) + assert after == before, "只看看也把缓存换掉了——助手此刻看到的就不是配置里那个 schema 了" + + +async def test_a_dry_run_that_finds_nothing_says_why_and_still_leaves_the_cache(client, source): + await client.post(f"/api/datasources/{source['id']}/introspect") + before = await _cache(source["id"]) + + r = await client.post(f"/api/datasources/{source['id']}/introspect", + params={"schema": "no_such_schema", "dry_run": "true"}) + preview = r.json() + assert preview["dry_run"] is True and preview["table_count"] == 0 + assert preview["schema_error"], "探失败了要说出来,不能看起来像「这个 schema 是空的」" + assert "main" in preview["available_schemas"] + assert await _cache(source["id"]) == before + + +async def test_without_dry_run_the_cache_is_still_replaced(client, source): + r = await client.post(f"/api/datasources/{source['id']}/introspect", params={"schema": "main"}) + assert r.json()["table_count"] == 3 + assert (await _cache(source["id"]))["cache"]["schema"] == "main" + + +# -------------------------------------------------------------------------- +# REQ-6 ②:表结构的结构化列 +# -------------------------------------------------------------------------- + + +async def test_table_schema_comes_with_structured_columns(client, source): + await client.post(f"/api/datasources/{source['id']}/introspect") + body = (await client.get(f"/api/datasources/{source['id']}/schema", + params={"table": "orders"})).json() + assert body["found"] is True + assert body["kind"] == "table" and body["qualified"] == "orders" + assert body["detail"].startswith("表 orders"), "给模型看的那段文本照旧" + assert body["columns"] == [ + {"name": "id", "type": "INTEGER", "pk": True, "not_null": False, "comment": None}, + {"name": "store", "type": "TEXT", "pk": False, "not_null": True, "comment": None}, + {"name": "amount", "type": "REAL", "pk": False, "not_null": False, "comment": None}, + ] + + view = (await client.get(f"/api/datasources/{source['id']}/schema", + params={"table": "V_BIG_ORDERS"})).json() + assert view["found"] is True and view["kind"] == "view", "和给模型的那份一样,大小写不敏感" + + +async def test_an_unknown_table_has_no_columns_but_keeps_the_explanation(client, source): + await client.post(f"/api/datasources/{source['id']}/introspect") + body = (await client.get(f"/api/datasources/{source['id']}/schema", + params={"table": "nope"})).json() + assert body["found"] is False and body["columns"] == [] + assert "没有 nope" in body["detail"] diff --git a/backend/tests/test_graph_checks.py b/backend/tests/test_graph_checks.py new file mode 100644 index 00000000..2b884faa --- /dev/null +++ b/backend/tests/test_graph_checks.py @@ -0,0 +1,252 @@ +"""校验要指到字段上;提示词点名要用的工具,节点得真的绑了它。 + +field:检查器要把问题落到具体的输入框下面。以前 ValidationIssue 只有 node_id,前端 +只能拿消息原文去猜是哪个字段(认 {{ path }}、认几句固定说法),认不出就只挂在 +面板顶上。 + +工具绑定:一次真实的运行里,助手改图时把三个查库 agent 的 tools 丢了,提示词里却 +还写着「用 db_query__… 查」。模型拿不到工具,只能写一句「假设调用工具」,再往下 +编。这种图在跑之前就能认出来:提示词点名了工具、节点却没绑定,判 error—— +Copilot 的自查读的是同一份 error,搭图时就会交回模型去修。 +""" +from __future__ import annotations + +import pytest + +from app.engine.schema import GraphSpec, validate_graph + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def graph(*nodes, edges=None): + ids = [n["id"] for n in nodes] + return GraphSpec.model_validate({ + "nodes": list(nodes), + "edges": edges if edges is not None else + [{"source": a, "target": b} for a, b in zip(ids, ids[1:])], + }) + + +def issues_of(spec, node_id=None): + return [i for i in validate_graph(spec).issues if node_id is None or i.node_id == node_id] + + +# -------------------------------------------------------------------------- +# 提示词点名的工具 +# -------------------------------------------------------------------------- + + +def test_a_named_tool_that_is_not_bound_is_an_error(): + spec = graph( + node("start", "input", fields=[{"name": "question"}]), + node("query", "agent", model="m", system="你负责取数。", + prompt="用 db_query__shop 查 orders 表的总数:{{ input.question }}", tools=[]), + node("out", "output"), + ) + errors = [i for i in issues_of(spec, "query") if i.level == "error"] + assert errors, issues_of(spec) + assert "提示词要求用「db_query__shop」,但节点没有绑定它" in errors[0].message + assert errors[0].field == "prompt" + assert not validate_graph(spec).ok + + +def test_builtin_tool_names_are_recognised_too(): + spec = graph( + node("start", "input"), + node("calc", "agent", model="m", system="算完用 python_exec 核对一遍", tools=["calculator"]), + ) + hit = [i for i in issues_of(spec, "calc") if "python_exec" in i.message] + assert hit and hit[0].level == "error" and hit[0].field == "system" + + +def test_a_bound_tool_is_fine(): + spec = graph( + node("start", "input"), + node("query", "agent", model="m", prompt="用 db_query__shop 查一下", tools=["db_query__shop"]), + ) + assert not [i for i in issues_of(spec, "query") if "提示词要求用" in i.message] + + +def test_saying_not_to_use_a_tool_is_not_asking_for_it(): + spec = graph( + node("start", "input"), + node("query", "agent", model="m", prompt="不要用 python_exec,直接心算", tools=[]), + ) + assert not [i for i in issues_of(spec, "query") if "python_exec" in i.message] + + +def test_a_vague_request_for_tools_without_any_bound_is_a_warning(): + spec = graph( + node("start", "input"), + node("query", "agent", model="m", prompt="调用工具查一下数据库,再回答", tools=[]), + ) + vague = [i for i in issues_of(spec, "query") if "没有绑定任何工具" in i.message] + assert vague and vague[0].level == "warning" and vague[0].field == "prompt" + assert validate_graph(spec).ok + + +def test_team_members_are_checked_against_their_own_tools(): + spec = graph( + node("start", "input"), + node("team", "supervisor", model="m", goal="核对迁移记录", agents=[ + {"name": "核对员", "system": "用 db_query__shop 独立重查一遍条数", "tools": []}, + {"name": "撰稿员", "system": "根据复核结果写结论"}, + ]), + ) + errors = [i for i in issues_of(spec, "team") if i.level == "error"] + assert errors and "核对员" in errors[0].message and "db_query__shop" in errors[0].message + assert errors[0].field == "agents[0].system" + + +def test_a_team_goal_naming_a_tool_nobody_has(): + spec = graph( + node("start", "input"), + node("team", "supervisor", model="m", goal="先用 db_schema__shop 看表结构", agents=[ + {"name": "甲", "system": "你负责查", "tools": ["db_query__shop"]}]), + ) + errors = [i for i in issues_of(spec, "team") if i.level == "error"] + assert errors and errors[0].field == "goal", issues_of(spec) + + +# 误报:这条 error 会挡住运行,还会作为自查的阻断项交回 Copilot(模型可能反过来把 +# 禁用的工具绑上去),宁可漏判不可误判 + + +def _errors_about(tool, **config): + spec = graph(node("start", "input"), node("bot", "agent", model="m", **config)) + return [i for i in issues_of(spec, "bot") if i.level == "error" and tool in i.message] + + +@pytest.mark.parametrize("prompt", [ + "Never call web_search; answer from the notes only.", + "Avoid web_fetch.", + "Do not use web_search.", + "Don't use the web_search tool, and never call web_fetch or http_request.", + "You must not call web_search under any circumstances.", + "You cannot use web_fetch here, and no http_request either.", +]) +def test_english_negation_is_not_a_request(prompt): + for tool in ("web_search", "web_fetch", "http_request"): + assert not _errors_about(tool, prompt=prompt, tools=[]), (prompt, tool) + + +@pytest.mark.parametrize("prompt", [ + "查过一次就不再调用 python_exec,直接给结论", + "表结构已经给你了,无须再调用 db_schema__shop", + "拿到结果后别再用 db_query__shop", + "毋须使用 web_search", +]) +def test_more_chinese_negations(prompt): + for tool in ("python_exec", "db_schema__shop", "db_query__shop", "web_search"): + assert not _errors_about(tool, prompt=prompt, tools=[]), (prompt, tool) + + +def test_an_mcp_wildcard_covers_its_tools(): + assert not _errors_about("mcp:fs/read_file", prompt="用 mcp:fs/read_file 读 notes.md", + tools=["mcp:fs/*"]) + assert not _errors_about("mcp:fs/read_file", prompt="用 mcp:fs/read_file 读 notes.md", + tools=["mcp:fs"]) + # 别的 server 的通配不算 + assert _errors_about("mcp:fs/read_file", prompt="用 mcp:fs/read_file 读 notes.md", + tools=["mcp:web/*"]) + + +def test_a_team_goal_is_covered_by_a_member_wildcard(): + spec = graph( + node("start", "input"), + node("team", "supervisor", model="m", goal="先用 mcp:fs/list_dir 看目录", agents=[ + {"name": "甲", "system": "你负责查", "tools": ["mcp:fs/*"]}]), + ) + assert not [i for i in issues_of(spec, "team") if i.level == "error"], issues_of(spec) + + +@pytest.mark.parametrize("prompt", [ + "You may use a calculator if needed.", + "Work it out like a calculator would, step by step.", + "如有需要可以用 calculator 核对", + "必要时用 python_exec 验算一下", + "You can call db_query__shop when needed.", +]) +def test_plain_words_and_optional_mentions_are_only_warnings(prompt): + spec = graph(node("start", "input"), node("bot", "agent", model="m", prompt=prompt, tools=[])) + found = issues_of(spec, "bot") + assert not [i for i in found if i.level == "error"], (prompt, found) + + +def test_a_plain_word_tool_still_counts_when_clearly_asked_for(): + for prompt in ("用 calculator 算出总价", "Use calculator to add them up.", "call `calculator` twice"): + errors = _errors_about("calculator", prompt=prompt, tools=[]) + assert errors and errors[0].field == "prompt", prompt + # 提到了但没明确要求:给一条 warning,不挡运行 + spec = graph(node("start", "input"), + node("bot", "agent", model="m", prompt="You may use a calculator if needed.", tools=[])) + warned = [i for i in issues_of(spec, "bot") if "calculator" in i.message] + assert warned and warned[0].level == "warning", warned + assert validate_graph(spec).ok + + +# -------------------------------------------------------------------------- +# field:指到字段 +# -------------------------------------------------------------------------- + + +def test_issues_carry_the_field_they_are_about(): + spec = graph( + node("start", "input", fields=[{"name": "question"}]), + node("route", "branch", cases=[ + {"key": "a", "condition": "vars.x == 1"}, + {"key": "b", "condition": "vars.y ==="}, + ]), + node("shape", "transform", mode="expression", expression=""), + node("say", "llm", model="m", prompt="回答 {{ input.questoin }}"), + edges=[{"source": "start", "target": "route"}, + {"source": "route", "target": "shape", "sourceHandle": "a"}, + {"source": "shape", "target": "say"}], + ) + by = {(i.node_id, i.field) for i in validate_graph(spec).issues} + assert ("route", "cases[1].condition") in by, by + assert ("route", "cases[1].key") in by or ("route", "cases") in by, by # b 没有连出去的边 + assert ("shape", "expression") in by, by + assert ("say", "prompt") in by, by # 变量拼错 + + +def test_graph_level_issues_have_no_field(): + spec = GraphSpec.model_validate({"nodes": []}) + assert all(i.field is None for i in validate_graph(spec).issues) + + +# -------------------------------------------------------------------------- +# 报错的人话规则只有一份 +# -------------------------------------------------------------------------- + + +def test_one_set_of_words_for_both_layers(): + import importlib.util + + import httpx + + from app.core.errors import describe_exception, explain + + for old in ("app.engine.errors", "app.api.errors"): + assert importlib.util.find_spec(old) is None, f"{old} 不该再有自己的一份" + req = httpx.Request("GET", "https://x/v1/models") + denied = httpx.HTTPStatusError("401", request=req, response=httpx.Response(401, request=req)) + assert describe_exception(denied) == explain(denied)[0] + assert describe_exception(TimeoutError()) == explain(TimeoutError())[0] + # 分叉只在程序自己的毛病上:接口里是后端 bug,节点里多半是参数传错了 + wrong = TypeError("_run() got an unexpected keyword argument 'query'") + assert "内部" in explain(wrong)[0] + assert "query" in describe_exception(wrong) + + +def test_a_list_of_forbidden_tools_is_not_a_request(): + spec = graph( + node("start", "input"), + node("team", "supervisor", model="m", goal="只用内部资料,严禁调用 web_search、web_fetch、http_request 等联网工具", + agents=[{"name": "甲", "system": "禁止使用 web_fetch 和 http_request,只查知识库", + "tools": ["kb_search"]}]), + ) + assert not [i for i in issues_of(spec, "team") if i.level == "error"], issues_of(spec) diff --git a/backend/tests/test_health_record.py b/backend/tests/test_health_record.py new file mode 100644 index 00000000..bf1b58e5 --- /dev/null +++ b/backend/tests/test_health_record.py @@ -0,0 +1,209 @@ +"""最近一次测连接的结果记在对象上,换台浏览器也看得到。 + +以前这份结果只存在当前浏览器的 localStorage 里:换台电脑、清个缓存,所有数据源、 +模型接入、MCP 服务都回到「未测试」,而它们上周是不是连得通,谁也说不清。 + +记在对象上还有两条规矩: +- 在表单里测过、紧接着保存的,保存下来的对象直接带上那次结果——后端自己核对 + 过测的就是这份配置,不用前端转述。 +- 连接配置改过了,旧结果就不再代表它,清掉;只改说明之类的不清。 +""" +from __future__ import annotations + +import sqlite3 + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.base import SessionLocal +from app.db.models import Provider +from app.main import app + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +def orders_db(tmp_path): + path = tmp_path / "orders.db" + db = sqlite3.connect(path) + db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL)") + db.commit() + db.close() + return str(path) + + +def _health(item: dict) -> tuple: + return (item["last_check_ok"], item["last_error"], + item["last_checked_at"] is not None, item["last_latency_ms"] is not None) + + +async def _source(client, name: str) -> dict: + return next(s for s in (await client.get("/api/datasources")).json() if s["name"] == name) + + +# -------------------------------------------------------------------------- +# 数据源 +# -------------------------------------------------------------------------- + + +async def test_testing_a_saved_source_is_remembered_on_it(client, orders_db): + r = await client.post("/api/datasources", json={"name": "health_src", "kind": "sqlite", + "database": orders_db}) + src = r.json() + try: + assert _health(src) == (None, None, False, False), "没测过就是没测过" + body = (await client.post(f"/api/datasources/{src['id']}/test")).json() + assert body["ok"] is True + assert _health(await _source(client, "health_src")) == (True, None, True, True) + + # 连接配置变了:旧结果不再代表它 + r = await client.patch(f"/api/datasources/{src['id']}", json={"database": orders_db + ".gone"}) + assert _health(r.json()) == (None, None, False, False) + body = (await client.post(f"/api/datasources/{src['id']}/test")).json() + assert body["ok"] is False + item = await _source(client, "health_src") + assert item["last_check_ok"] is False and item["last_error"] == body["error"] + assert item["last_checked_at"] + + # 只改说明:连接没变,结果留着 + r = await client.patch(f"/api/datasources/{src['id']}", json={"description": "订单库"}) + assert r.json()["last_check_ok"] is False + finally: + await client.delete(f"/api/datasources/{src['id']}") + + +async def test_a_draft_tested_then_saved_keeps_its_result(client, orders_db): + body = (await client.post("/api/datasources/test", json={"kind": "sqlite", + "database": orders_db})).json() + assert body["ok"] is True + r = await client.post("/api/datasources", json={"name": "health_draft", "kind": "sqlite", + "database": orders_db}) + src = r.json() + try: + assert _health(src) == (True, None, True, True), "刚在表单里测通的,保存之后不该变回未测试" + assert src["last_latency_ms"] == body["elapsed_ms"] + finally: + await client.delete(f"/api/datasources/{src['id']}") + + +async def test_a_draft_result_does_not_stick_to_a_different_config(client, orders_db, tmp_path): + await client.post("/api/datasources/test", json={"kind": "sqlite", "database": orders_db}) + other = tmp_path / "other.db" + sqlite3.connect(other).close() + r = await client.post("/api/datasources", json={"name": "health_other", "kind": "sqlite", + "database": str(other)}) + src = r.json() + try: + assert _health(src) == (None, None, False, False) + finally: + await client.delete(f"/api/datasources/{src['id']}") + + +async def test_a_draft_test_of_the_unchanged_saved_config_updates_it(client, orders_db): + r = await client.post("/api/datasources", json={"name": "health_edit", "kind": "sqlite", + "database": orders_db}) + src = r.json() + try: + await client.post("/api/datasources/test", json={"id": src["id"], "kind": "sqlite", + "database": orders_db}) + assert (await _source(client, "health_edit"))["last_check_ok"] is True + # 编辑框里改了路径再测:测的是另一份配置,不能记到已保存的那个头上 + await client.post("/api/datasources/test", json={"id": src["id"], "kind": "sqlite", + "database": orders_db + ".typo"}) + assert (await _source(client, "health_edit"))["last_check_ok"] is True + finally: + await client.delete(f"/api/datasources/{src['id']}") + + +# -------------------------------------------------------------------------- +# 模型接入 +# -------------------------------------------------------------------------- + + +async def _provider(client, name: str) -> dict: + return next(p for p in (await client.get("/api/providers")).json() if p["name"] == name) + + +async def test_testing_a_saved_provider_is_remembered_on_it(client): + r = await client.post("/api/providers", json={"name": "health-mock", "kind": "mock", + "default_model": "mock-fast"}) + prov = r.json() + try: + assert _health(prov) == (None, None, False, False) + assert (await client.post(f"/api/providers/{prov['id']}/test", json={})).json()["ok"] is True + item = await _provider(client, "health-mock") + assert _health(item) == (True, None, True, True) + assert "last_check" not in item["extra"], "结果是后端记的,不该混进用户填的 extra 里回显" + + # 前端保存时会把 extra 原样带回来:不能因此把结果抹掉 + r = await client.patch(f"/api/providers/{prov['id']}", json={"extra": item["extra"], + "enabled": True}) + assert r.json()["last_check_ok"] is True + # 换了默认模型:测的那个不是它了 + r = await client.patch(f"/api/providers/{prov['id']}", json={"default_model": "mock-slow"}) + assert _health(r.json()) == (None, None, False, False) + finally: + await client.delete(f"/api/providers/{prov['id']}") + + +async def test_a_provider_draft_tested_then_saved_keeps_its_result(client): + body = (await client.post("/api/providers/test", json={"kind": "mock", + "default_model": "mock-fast"})).json() + assert body["ok"] is True + r = await client.post("/api/providers", json={"name": "health-draft", "kind": "mock", + "default_model": "mock-fast"}) + prov = r.json() + try: + assert _health(prov) == (True, None, True, True) + finally: + await client.delete(f"/api/providers/{prov['id']}") + + +async def test_saving_a_provider_does_not_replace_its_headers_with_the_mask(client): + """列表里请求头的值回显成 ***;设置页原样带回来保存,真值就被 *** 盖掉了。""" + r = await client.post("/api/providers", json={ + "name": "health-headers", "kind": "openai_compatible", "base_url": "http://127.0.0.1:9/v1", + "api_key": "k", "default_model": "m", "extra": {"headers": {"X-Token": "real-value"}}, + }) + prov = r.json() + try: + shown = (await _provider(client, "health-headers"))["extra"] + assert shown["headers"] == {"X-Token": "***"} + await client.patch(f"/api/providers/{prov['id']}", json={ + "extra": {**shown, "headers": {**shown["headers"], "X-Other": "new"}}}) + async with SessionLocal() as session: + row = await session.get(Provider, prov["id"]) + assert row.extra["headers"] == {"X-Token": "real-value", "X-Other": "new"} + finally: + await client.delete(f"/api/providers/{prov['id']}") + + +# -------------------------------------------------------------------------- +# MCP +# -------------------------------------------------------------------------- + + +async def test_probing_an_mcp_server_is_remembered_with_when_and_how_long(client): + r = await client.post("/api/mcp/servers", json={ + "name": "health-mcp", "transport": "stdio", "command": "agentlab-no-such-command-xyz"}) + server = r.json() + try: + assert _health(server) == (None, None, False, False) + body = (await client.post(f"/api/mcp/servers/{server['id']}/probe")).json() + assert body["ok"] is False + item = next(s for s in (await client.get("/api/mcp/servers")).json() if s["id"] == server["id"]) + assert item["last_check_ok"] is False and item["last_error"] == body["error"] + assert item["last_checked_at"] and item["last_latency_ms"] is not None + + # 改了启动命令:旧结论不再算数 + r = await client.patch(f"/api/mcp/servers/{server['id']}", json={ + "name": "health-mcp", "transport": "stdio", "command": "another-command"}) + assert _health(r.json()) == (None, None, False, False) + assert r.json()["status"] == "unknown" + finally: + await client.delete(f"/api/mcp/servers/{server['id']}") diff --git a/backend/tests/test_issuance_matched.py b/backend/tests/test_issuance_matched.py new file mode 100644 index 00000000..1a4d17fc --- /dev/null +++ b/backend/tests/test_issuance_matched.py @@ -0,0 +1,64 @@ +"""出具时逐个说清:叙述里的每个数字回指到了哪张口径卡的哪个指标。 + +以前事件和成果里只有 matched_numbers 这一个计数。界面想在正文里把数字逐个标出 +「来自口径卡指标 orders · 销售口径 @ v2」,拿不到对应关系,只能标出没对上的那些。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.runner import run_manager + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +async def test_each_traced_number_names_its_metric_and_caliber(): + graph = { + "nodes": [ + node("start", "input"), + node("card", "metrics", caliber="销售口径", caliber_version="v2", metrics=[ + {"id": "orders", "expression": "120"}, + {"id": "aov", "expression": "35.5"}, + ]), + node("draft", "transform", mode="template", assign_to="summary", + template="本周订单 120 单,客单价 35.5 元,另有 7 单"), + node("out", "output", fields=[{"name": "结论", "value": "{{ vars.summary }}"}], + contract={"metrics_from": "card", "narrative": "{{ vars.summary }}"}), + ], + "edges": [{"source": "start", "target": "card"}, {"source": "card", "target": "draft"}, + {"source": "draft", "target": "out"}], + } + run = await run_manager.start(graph=graph, input_payload={}) + for _ in range(200): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed"): + break + assert row.status == "succeeded", row.error + + expected = [{"token": "120", "metric": "orders", "caliber": "销售口径 @ v2"}, + {"token": "35.5", "metric": "aov", "caliber": "销售口径 @ v2"}] + assert row.output["_issuance"]["matched"] == expected + assert row.output["_issuance"]["matched_numbers"] == 2 + async with SessionLocal() as session: + issuance = (await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id, RunEvent.type == "issuance") + )).scalars().one() + assert issuance.data["matched"] == expected + assert issuance.data["matched_numbers"] == 2 diff --git a/backend/tests/test_kb_progress.py b/backend/tests/test_kb_progress.py new file mode 100644 index 00000000..3d8055a1 --- /dev/null +++ b/backend/tests/test_kb_progress.py @@ -0,0 +1,257 @@ +"""知识库调试台看得见每条命中的分从哪来;重建索引有真实进度。 + +总分是向量、关键词两路各自在候选集上归一化之后按 α 加权的和。前端拿到的只有 +两路的原始分(余弦、BM25),复现不了那个归一化,于是命中只能画一根总分条, +说不清「这条是靠语义排上来的还是靠关键词」。contrib 把两路的贡献直接交出去, +两者之和就是总分。 + +重建索引以前压在一个请求里:几千段配远端 embedding 要好几分钟,界面只能转圈 +加一个已用时间。改成后台任务之后按批报「总数 / 已完成」,界面才画得出真进度。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Chunk, MemoryItem +from app.main import app +from app.memory import embeddings as emb +from app.memory import kb, store + + +@pytest.fixture(autouse=True) +def _local_embedder(): + emb.configure("local") + yield + emb.configure("local") + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +DOCS = [ + "平台管理员一共有六个人,负责审批权限。", + "商家账户共五个,按地区划分。", + "订单表 orders 记录每一笔订单的金额和门店。", + "每日销售汇总在 sales_daily 表里,按门店和日期聚合。", +] + + +async def _seed(collection: str) -> None: + async with SessionLocal() as session: + for text in DOCS: + await kb.ingest_document(session, collection=collection, title=text[:8], content=text) + + +# -------------------------------------------------------------------------- +# 命中的分数贡献 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("alpha", [0.0, 0.3, 0.7, 1.0]) +async def test_each_hit_says_how_much_each_signal_contributed(client, alpha): + await _seed(f"contrib_{int(alpha * 10)}") + r = await client.get("/api/kb/search", params={ + "q": "订单 门店", "collection": f"contrib_{int(alpha * 10)}", "limit": 4, "alpha": alpha, + }) + hits = r.json()["results"] + assert hits + for hit in hits: + contrib = hit["contrib"] + assert set(contrib) == {"vector", "keyword"} + assert abs(contrib["vector"] + contrib["keyword"] - hit["score"]) < 1e-6, hit + assert 0 <= contrib["vector"] <= alpha + 1e-9 + assert 0 <= contrib["keyword"] <= 1 - alpha + 1e-9 + if alpha == 0.0: + assert all(h["contrib"]["vector"] == 0 for h in hits) + if alpha == 1.0: + assert all(h["contrib"]["keyword"] == 0 for h in hits) + # 原始信号照旧给:调试台上还要写余弦和 BM25 的原数 + assert set(hits[0]["signals"]) == {"vector", "keyword"} + + +async def test_the_contributions_come_from_the_same_normalisation_as_the_score(): + from app.memory.embeddings import hybrid_contrib, hybrid_rank + + texts = ["订单金额", "门店数量", "订单和门店"] + vectors = [await emb.embed_text(t) for t in texts] + query = await emb.embed_text("订单") + ranked = hybrid_rank("订单", texts, vectors, query, alpha=0.4) + detailed = hybrid_contrib("订单", texts, vectors, query, alpha=0.4) + assert [(i, s) for i, s, _ in ranked] == [(r.index, r.score) for r in detailed] + for r in detailed: + assert abs(r.contrib["vector"] + r.contrib["keyword"] - r.score) < 1e-12 + + +async def test_a_degraded_search_puts_everything_on_keywords(client): + await _seed("contrib_stale") + async with SessionLocal() as session: + for row in (await session.execute( + select(Chunk).where(Chunk.collection == "contrib_stale"))).scalars(): + row.embed_model, row.embed_dim = "旧模型", 7 + await session.commit() + body = (await client.get("/api/kb/search", params={ + "q": "订单", "collection": "contrib_stale", "alpha": 0.7})).json() + assert body["degraded"] + assert all(h["contrib"]["vector"] == 0 for h in body["results"]) + + +# -------------------------------------------------------------------------- +# 重建索引:后台跑,按批报进度 +# -------------------------------------------------------------------------- + + +async def test_reindex_reports_progress_batch_by_batch(monkeypatch): + await _seed("progress_kb") + monkeypatch.setattr(kb, "_EMBED_BATCH", 2) + seen: list[tuple[int, int]] = [] + async with SessionLocal() as session: + out = await kb.reindex(session, "progress_kb", on_progress=lambda d, t: seen.append((d, t))) + assert out["reindexed"] == len(DOCS) + assert seen == [(2, 4), (4, 4)] + + +async def test_memory_reindex_reports_progress_too(monkeypatch): + async with SessionLocal() as session: + for i in range(3): + await store.remember(session, scope="progress_mem", content=f"第 {i} 条要记住的口径", + dedupe=False) + monkeypatch.setattr(kb, "_EMBED_BATCH", 2) + seen: list[tuple[int, int]] = [] + async with SessionLocal() as session: + n = await store.reindex(session, "progress_mem", on_progress=lambda d, t: seen.append((d, t))) + assert n == 3 and seen == [(2, 3), (3, 3)] + + +async def _wait_done(client, timeout: float = 10.0) -> dict: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + job = (await client.get("/api/kb/reindex")).json() + if job["state"] != "running" or loop.time() > deadline: + return job + await asyncio.sleep(0.02) + + +async def test_a_background_reindex_returns_at_once_and_can_be_polled(client): + await _seed("progress_bg") + async with SessionLocal() as session: + for row in (await session.execute( + select(Chunk).where(Chunk.collection == "progress_bg"))).scalars(): + row.embed_model, row.embed_dim = "旧模型", 7 + await session.commit() + + r = await client.post("/api/kb/reindex", params={"collection": "progress_bg", "background": "true"}) + assert r.status_code == 202, r.text + started = r.json() + assert started["state"] in ("running", "done") + assert started["collection"] == "progress_bg" + memories = await _count_memories() + assert started["chunks"]["total"] == len(DOCS), "总数要在开始时就知道,进度条才有分母" + assert started["total"] == len(DOCS) + memories + + job = await _wait_done(client) + assert job["state"] == "done", job + assert job["id"] == started["id"] + assert job["done"] == job["total"] + assert job["chunks"] == {"total": len(DOCS), "done": len(DOCS)} + assert job["result"]["reindexed"] == len(DOCS) + assert job["result"]["memories_reindexed"] == memories + assert job["finished_at"] + async with SessionLocal() as session: + assert await kb.stale_count(session, "progress_bg") == 0 + + +async def test_a_second_reindex_while_one_is_running_is_refused(client, monkeypatch): + from app.api import knowledge + + gate = asyncio.Event() + real = kb.reindex + + async def slow(session, collection=None, *, on_progress=None): + await gate.wait() + return await real(session, collection, on_progress=on_progress) + + monkeypatch.setattr(kb, "reindex", slow) + try: + # 后台跑的话这一步立刻返回;压在请求里跑的话会一直等着 gate + first = await asyncio.wait_for( + client.post("/api/kb/reindex", params={"background": "true"}), timeout=5) + assert first.status_code == 202 + again = await client.post("/api/kb/reindex", params={"background": "true"}) + assert again.status_code == 409 + assert "重建" in again.json()["detail"] + sync = await asyncio.wait_for(client.post("/api/kb/reindex"), timeout=5) + assert sync.status_code == 409, "同步那条路也不能和后台的并发跑" + finally: + gate.set() + job = await _wait_done(client) + assert job["state"] == "done" + assert knowledge.reindex_job() is not None + + +async def test_two_reindexes_started_at_the_same_moment_do_not_both_run(client, monkeypatch): + """连点两下、两个标签页同时点:只能起一个。 + + 先查「有没有在跑」、再 await 两次计数、最后才记下任务,中间的 await 让第二个 + 请求也看到「没在跑」——两个都 202,embedding 被重复计费,第一个的进度被覆盖。 + """ + gate = asyncio.Event() + real = kb.reindex + started: list[str | None] = [] + + async def slow(session, collection=None, *, on_progress=None): + started.append(collection) + await gate.wait() + return await real(session, collection, on_progress=on_progress) + + monkeypatch.setattr(kb, "reindex", slow) + try: + replies = await asyncio.wait_for(asyncio.gather(*( + client.post("/api/kb/reindex", params={"background": "true"}) for _ in range(3) + )), timeout=5) + codes = sorted(r.status_code for r in replies) + assert codes == [202, 409, 409], [(r.status_code, r.text) for r in replies] + await asyncio.sleep(0.05) + assert len(started) == 1, "只能真的起一个重建" + finally: + gate.set() + job = await _wait_done(client) + assert job["state"] == "done" + accepted = next(r for r in replies if r.status_code == 202).json() + assert job["id"] == accepted["id"], "进度查到的得是被接下来的那一个" + + +async def test_a_failed_reindex_says_why(client, monkeypatch): + async def broken(session, collection=None, *, on_progress=None): + raise RuntimeError("Connection refused") + + monkeypatch.setattr(kb, "reindex", broken) + r = await client.post("/api/kb/reindex", params={"background": "true"}) + assert r.status_code == 202 + job = await _wait_done(client) + assert job["state"] == "failed" + assert job["error"] and "RuntimeError" not in job["error"] + + +async def test_before_any_reindex_the_status_is_idle(client, monkeypatch): + from app.api import knowledge + + monkeypatch.setattr(knowledge, "_REINDEX", None) + job = (await client.get("/api/kb/reindex")).json() + assert job == {"state": "idle"} + + +async def _count_memories() -> int: + from sqlalchemy import func + + async with SessionLocal() as session: + return int((await session.execute(select(func.count(MemoryItem.id)))).scalar_one()) diff --git a/backend/tests/test_markup_leak.py b/backend/tests/test_markup_leak.py new file mode 100644 index 00000000..3d1aea45 --- /dev/null +++ b/backend/tests/test_markup_leak.py @@ -0,0 +1,234 @@ +"""模型把工具调用当成文字写出来,这一步没有真的调用工具——要认出来,不能当成功交出去。 + +一次真实运行的最终答案是一整段 `<||DSML|| invoke name="db_schema__…">` 标记: +协作成员没有绑定工具,模型把自己那套工具调用格式当正文输出了四轮,数据库一次 +都没查,运行却是 succeeded。换成 agent 节点、单次模型调用也是一样。 + +处理:先带一句纠正提示重试一次;仍然如此就判失败,说清原因和怎么办,并发一条 +code=tool_markup_leak 的警告。协作成员这样时,这一步记为失败,原因交回调度者。 +""" +from __future__ import annotations + +import asyncio +import json + +import pytest +from langchain_core.messages import AIMessage, HumanMessage +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.runner import run_manager +from app.engine.toolcalls import leaked_markup + +DSML = ('我将真正执行数据库工具,先取表结构。\n\n<||DSML|| calls>\n' + '<||DSML|| invoke name="db_schema__shop">\n' + '<||DSML|| parameter name="table">orders\n' + '\n') + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), + "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def _finish(graph) -> tuple[Run, list[RunEvent]]: + run = await run_manager.start(graph=graph, input_payload={"question": "orders 有多少"}) + for _ in range(300): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed"): + break + async with SessionLocal() as session: + events = list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq) + )).scalars()) + return row, events + + +def _leaks(monkeypatch, *, until: int | None = None) -> list[list]: + """模型一直(或前 until 次)把工具调用写成文字。返回每次收到的消息。""" + from app.providers import mock_model + + seen: list[list] = [] + + def _decide(self, messages): + if self.response_format: # 协作团队的调度者:先派查数员,之后收工 + routed = sum(1 for batch in seen if batch and batch[0] == "route") + seen.append(["route", "\n".join(str(m.content) for m in messages)]) + plan = ({"assignments": [{"agent": "查数员", "instruction": "查 orders 有多少"}]} + if routed == 0 else {"assignments": [], "done": True, "reason": "够了"}) + return AIMessage(content=json.dumps(plan, ensure_ascii=False)) + seen.append(list(messages)) + calls = sum(1 for batch in seen if batch and batch[0] != "route") + if until is None or calls <= until: + return AIMessage(content=DSML) + return AIMessage(content="orders 一共 12 条。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + return seen + + +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("text", [ + DSML, + "<|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>db_query__shop", + '\n{"name": "db_query__shop", "arguments": {"sql": "SELECT 1"}}\n', + '{"name": "python_exec"}', + 'SELECT 1', + '假设调用工具得到:{"tool": "db_query__shop", "sql": "SELECT COUNT(*) FROM orders"}', +]) +def test_markup_is_recognised(text): + assert leaked_markup(text) + + +@pytest.mark.parametrize("text", [ + "orders 一共 12 条。", + "查询用的是 `SELECT COUNT(*) FROM orders`,结果 12。", + '返回的 JSON 是 {"tool": "calculator", "result": 3}', # 没自称「假设调用」 + "工具调用失败了,原因是连接超时。", +]) +def test_ordinary_answers_are_not(text): + assert not leaked_markup(text) + + +async def test_an_agent_that_keeps_writing_markup_fails(monkeypatch): + _leaks(monkeypatch) + row, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("query", "agent", prompt="查 orders 有多少", tools=[], max_steps=4), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.query.text }}"}]), + )) + assert row.status == "failed", f"一堆标记被当成了答案:{row.output}" + assert "工具调用的原始标记" in (row.error or "") and "没有真正调用工具" in (row.error or "") + assert row.error_node_id == "query" + assert "tool_markup_leak" in [e.data.get("code") for e in events if e.type == "log"] + + +async def test_an_agent_gets_one_corrective_retry(monkeypatch): + seen = _leaks(monkeypatch, until=1) + row, _ = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("query", "agent", prompt="查 orders 有多少", tools=[], max_steps=4), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.query.text }}"}]), + )) + assert row.status == "succeeded", row.error + assert row.output["r"] == "orders 一共 12 条。" + retry = seen[1] + assert isinstance(retry[-1], HumanMessage) and "没有" in str(retry[-1].content) + + +async def test_a_plain_model_call_that_writes_markup_fails(monkeypatch): + _leaks(monkeypatch) + row, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("say", "llm", prompt="回答 {{ input.question }}"), + node("out", "output", fields=[]), + )) + assert row.status == "failed" + assert "工具调用的原始标记" in (row.error or "") + ends = [e for e in events if e.type == "llm.end" and e.node_id == "say"] + assert len(ends) == 2, "重试那一次也是一次模型调用,要记账" + + +async def test_a_team_member_writing_markup_fails_its_step_and_the_coordinator_is_told(monkeypatch): + seen = _leaks(monkeypatch) + row, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("team", "supervisor", goal="查 orders 有多少", max_rounds=3, agents=[ + {"name": "查数员", "system": "你负责查库", "max_steps": 3}]), + node("out", "output", fields=[]), + )) + step = next(e.data for e in events if e.type == "agent.step.end") + assert step.get("failed") is True and "原始标记" in step["preview"], step + assert "tool_markup_leak" in [e.data.get("code") for e in events if e.type == "log"] + # 调度者下一轮读到的进展里有这条失败的原因 + routes = [batch[1] for batch in seen if batch and batch[0] == "route"] + assert len(routes) == 2 and "原始标记" in routes[1], routes + assert "<||DSML" not in json.dumps(row.output, ensure_ascii=False) + + +async def test_markup_in_the_settle_round_keeps_what_the_agent_found(monkeypatch): + """步数用完后的收尾轮(不带工具)写出调用标记:不是「没绑工具」,不能判失败、把查到的丢掉。""" + from langchain_core.messages import ToolMessage + + from app.providers import mock_model + + def _decide(self, messages): + if self.tools: # 前面的步数:真的在调工具 + done = sum(isinstance(m, ToolMessage) for m in messages) + return AIMessage(content=f"先算第 {done + 1} 步。", tool_calls=[ + {"name": "calculator", "args": {"expression": f"{done} + 1"}, "id": f"c{done}"}]) + return AIMessage(content=DSML) # 收尾轮:还想接着查 + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + row, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("query", "agent", prompt="算一下", tools=["calculator"], max_steps=2), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.query.text }}"}]), + )) + assert row.status == "succeeded", row.error + assert [e.type for e in events].count("tool.end") == 2 + assert "<||DSML" not in row.output["r"] + assert "先算第 2 步。" in row.output["r"] and "用满了 2 步" in row.output["r"], row.output + leak = [e.data for e in events if e.type == "log" and e.data.get("code") == "tool_markup_leak"] + assert leak and "收尾轮" in leak[0]["message"], leak + + +async def test_markup_to_the_very_end_without_any_real_call_still_fails(monkeypatch): + """纠正之后步数就用完了、收尾轮还是标记:一次真调用都没有,仍是「调不了工具」。""" + _leaks(monkeypatch) + row, _ = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("query", "agent", prompt="查 orders 有多少", tools=["calculator"], max_steps=1), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.query.text }}"}]), + )) + assert row.status == "failed" and "工具调用的原始标记" in (row.error or ""), row.error + + +async def test_a_member_whose_settle_round_writes_markup_keeps_its_findings(monkeypatch): + from langchain_core.messages import ToolMessage + + from app.providers import mock_model + + routed = {"n": 0} + + def _decide(self, messages): + if self.response_format: + routed["n"] += 1 + plan = ({"assignments": [{"agent": "查数员", "instruction": "算"}]} if routed["n"] == 1 + else {"assignments": [], "done": True, "reason": "够了"}) + return AIMessage(content=json.dumps(plan, ensure_ascii=False)) + if self.tools: + done = sum(isinstance(m, ToolMessage) for m in messages) + return AIMessage(content=f"第 {done + 1} 步:先算一下。", tool_calls=[ + {"name": "calculator", "args": {"expression": "1 + 1"}, "id": f"c{done}"}]) + return AIMessage(content=DSML) + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + row, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("team", "supervisor", goal="算一下", max_rounds=3, agents=[ + {"name": "查数员", "system": "你负责算", "tools": ["calculator"], "max_steps": 2}]), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.team.text }}"}]), + )) + assert row.status == "succeeded", row.error + step = next(e.data for e in events if e.type == "agent.step.end") + assert not step.get("failed"), step + assert "第 2 步" in row.output["r"] and "<||DSML" not in row.output["r"], row.output + assert "tool_markup_leak" in [e.data.get("code") for e in events if e.type == "log"] diff --git a/backend/tests/test_mcp_cache.py b/backend/tests/test_mcp_cache.py new file mode 100644 index 00000000..22c9db44 --- /dev/null +++ b/backend/tests/test_mcp_cache.py @@ -0,0 +1,230 @@ +"""MCP 服务改了、删了、新加了,运行时拿到的工具要跟着变。 + +mcp_manager 按服务名缓存工具(每次都拉起一个 stdio 子进程太贵),而以前缓存只在 +「一个都没加载过」时才建:改了启动命令,运行照样用旧命令建出来的工具;删掉的服务 +照样能被节点调到;缓存建好之后新加的服务,工具列表里永远没有它,除非有人去点刷新。 + +这里用一个假的 MCP 客户端:它建出来的工具在 description 里写着自己是按哪个命令 +(或地址)连的,于是一眼看得出拿到的是新配置还是旧配置。 +""" +from __future__ import annotations + +from typing import Any + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.tools import StructuredTool + +from app.main import app + + +class _FakeClient: + """一次连接 = 一次 get_tools。connects 记下每次连的是哪个服务、按什么配置。""" + + connects: list[tuple[str, str]] = [] + + def __init__(self, servers: dict[str, dict[str, Any]]) -> None: + self.servers = servers + + async def get_tools(self) -> list[StructuredTool]: + (name, cfg), = self.servers.items() + via = cfg.get("command") or cfg.get("url") or "" + _FakeClient.connects.append((name, via)) + + async def _echo(text: str = "") -> str: + return f"{via}:{text}" + + return [StructuredTool.from_function(coroutine=_echo, name="echo", + description=f"via {via}")] + + +@pytest.fixture +def manager(monkeypatch): + """每个测试一个干净的 McpManager,接口层和运行时拿到的是同一个。""" + import langchain_mcp_adapters.client as mcp_client + + import app.api.tools as tools_api + import app.tools.mcp_manager as mcp_module + + _FakeClient.connects = [] + monkeypatch.setattr(mcp_client, "MultiServerMCPClient", _FakeClient) + fresh = mcp_module.McpManager() + monkeypatch.setattr(mcp_module, "mcp_manager", fresh) + monkeypatch.setattr(tools_api, "mcp_manager", fresh) + return fresh + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +async def server(client, manager): + """一个叫 files 的 stdio 服务,测完删掉(测试里已经删了的就不用再删)。""" + r = await client.post("/api/mcp/servers", json={ + "name": "files", "transport": "stdio", "command": "fs-v1"}) + assert r.status_code == 201, r.text + row = r.json() + yield row + await client.delete(f"/api/mcp/servers/{row['id']}") + + +def _body(row: dict[str, Any], **changes: Any) -> dict[str, Any]: + keys = ("name", "transport", "command", "args", "env", "url", "enabled") + return {**{k: row[k] for k in keys}, **changes} + + +async def _via(manager, ref: str) -> list[str]: + """运行时按引用取到的工具,各自是按什么配置连的。""" + return [t.description for t in await manager.get_tools([ref])] + + +async def test_changing_the_command_drops_the_tools_built_from_the_old_one(client, manager, server): + assert await _via(manager, "mcp:files/echo") == ["via fs-v1"] + + r = await client.patch(f"/api/mcp/servers/{server['id']}", json=_body(server, command="fs-v2")) + assert r.status_code == 200, r.text + assert "files" not in manager._tools, "保存之后旧工具就该丢掉,而不是等下次有人用" + + assert await _via(manager, "mcp:files/echo") == ["via fs-v2"], "改了启动命令,运行还在用旧命令建的工具" + listed = [t for t in await manager.list_tools() if t["server"] == "files"] + assert [t["description"] for t in listed] == ["via fs-v2"] + + +async def test_changing_the_address_of_an_http_server_reconnects(client, manager): + r = await client.post("/api/mcp/servers", json={ + "name": "remote", "transport": "http", "url": "https://mcp-a.example/mcp"}) + row = r.json() + try: + assert await _via(manager, "mcp:remote/*") == ["via https://mcp-a.example/mcp"] + await client.patch(f"/api/mcp/servers/{row['id']}", + json=_body(row, url="https://mcp-b.example/mcp")) + assert await _via(manager, "mcp:remote/*") == ["via https://mcp-b.example/mcp"] + finally: + await client.delete(f"/api/mcp/servers/{row['id']}") + + +async def test_a_deleted_server_is_gone_from_runs_and_from_the_list(client, manager, server): + assert await _via(manager, "mcp:files/*") == ["via fs-v1"] + + r = await client.delete(f"/api/mcp/servers/{server['id']}") + assert r.status_code == 204 + assert "files" not in manager._tools + + assert await manager.get_tools(["mcp:files/*"]) == [], "删掉的服务还能被节点调到" + assert not [t for t in await manager.list_tools() if t["server"] == "files"] + tools = (await client.get("/api/tools")).json() + assert not [t for t in tools if t["id"].startswith("mcp:files/")] + + +async def test_a_disabled_server_is_not_handed_out(client, manager, server): + assert await _via(manager, "mcp:files/*") == ["via fs-v1"] + await client.patch(f"/api/mcp/servers/{server['id']}", json=_body(server, enabled=False)) + assert await manager.get_tools(["mcp:files/*"]) == [] + + # 再打开:下次用时照常连上 + await client.patch(f"/api/mcp/servers/{server['id']}", json=_body(server, enabled=True)) + assert await _via(manager, "mcp:files/*") == ["via fs-v1"] + + +async def test_renaming_moves_the_tools_to_the_new_name(client, manager, server): + assert await _via(manager, "mcp:files/*") == ["via fs-v1"] + await client.patch(f"/api/mcp/servers/{server['id']}", json=_body(server, name="documents")) + assert await manager.get_tools(["mcp:files/*"]) == [] + assert await _via(manager, "mcp:documents/echo") == ["via fs-v1"] + + +async def test_a_server_added_after_the_cache_is_warm_shows_up(client, manager, server): + assert await _via(manager, "mcp:files/*") == ["via fs-v1"] + r = await client.post("/api/mcp/servers", json={ + "name": "later", "transport": "stdio", "command": "later-v1"}) + row = r.json() + try: + servers = {t["server"] for t in await manager.list_tools()} + assert {"files", "later"} <= servers, "缓存建好之后新加的服务,工具列表里没有它" + assert await _via(manager, "mcp:later/echo") == ["via later-v1"] + finally: + await client.delete(f"/api/mcp/servers/{row['id']}") + + +async def test_unchanged_servers_are_not_reconnected_on_every_use(client, manager, server): + for _ in range(3): + await manager.get_tools(["mcp:files/*"]) + await manager.list_tools() + assert _FakeClient.connects.count(("files", "fs-v1")) == 1 + + # 只改名字以外无关连接的保存(原样再存一次)也不该重连 + await client.patch(f"/api/mcp/servers/{server['id']}", json=_body(server)) + await manager.get_tools(["mcp:files/*"]) + assert _FakeClient.connects.count(("files", "fs-v1")) == 1 + + +async def test_a_change_made_behind_the_api_is_still_noticed(client, manager, server): + """缓存认的是连接配置,不只是接口层的通知:库里的配置变了,下次用时就重连。""" + from app.db.base import SessionLocal + from app.db.models import McpServer + + assert await _via(manager, "mcp:files/*") == ["via fs-v1"] + async with SessionLocal() as session: + row = await session.get(McpServer, server["id"]) + row.command = "fs-v3" + await session.commit() + assert await _via(manager, "mcp:files/*") == ["via fs-v3"] + + +async def test_a_load_that_raced_with_an_edit_is_not_cached(client, manager, server, monkeypatch): + """连接要花时间(stdio 子进程、最长 30 秒)。连到一半服务被改了,这次连的是旧配置, + 结果不能进缓存,也不能把旧配置的「连得上」写回库里。""" + import asyncio + + gate = asyncio.Event() + started = asyncio.Event() + original = _FakeClient.get_tools + + async def _slow(self): + started.set() + await gate.wait() + return await original(self) + + monkeypatch.setattr(_FakeClient, "get_tools", _slow) + loading = asyncio.create_task(manager.get_tools(["mcp:files/*"])) + await started.wait() + await client.patch(f"/api/mcp/servers/{server['id']}", json=_body(server, command="fs-v2")) + gate.set() + await loading # 这一次拿到什么都行:它开始时配置还是旧的 + + async def _row() -> dict[str, Any]: + return next(s for s in (await client.get("/api/mcp/servers")).json() if s["id"] == server["id"]) + + stale = await _row() + assert stale["status"] == "unknown" and stale["last_check_ok"] is None, \ + f"旧配置那次连接的结论写回了库里:{stale}" + + monkeypatch.setattr(_FakeClient, "get_tools", original) + assert await _via(manager, "mcp:files/*") == ["via fs-v2"] + fresh = await _row() + assert fresh["status"] == "ok" and fresh["last_check_ok"] is True + + +async def test_a_missing_command_is_named_in_the_error(client, manager, monkeypatch): + """stdio 客户端抛的 FileNotFoundError 常常不带文件名,报错里得补上配置的那条命令, + 不能只剩一句「找不到命令 」。""" + async def _missing(self): + raise FileNotFoundError() + + monkeypatch.setattr(_FakeClient, "get_tools", _missing) + r = await client.post("/api/mcp/servers", json={ + "name": "ghost", "transport": "stdio", "command": "ghost-mcp-server", "args": ["--stdio"]}) + row = r.json() + try: + assert await manager.get_tools(["mcp:ghost/*"]) == [] + listed = next(s for s in (await client.get("/api/mcp/servers")).json() if s["id"] == row["id"]) + assert listed["status"] == "error" + assert "ghost-mcp-server" in listed["last_error"], listed["last_error"] + probe = (await client.post(f"/api/mcp/servers/{row['id']}/probe")).json() + assert probe["ok"] is False and "ghost-mcp-server" in probe["error"], probe + finally: + await client.delete(f"/api/mcp/servers/{row['id']}") diff --git a/backend/tests/test_query_deadline.py b/backend/tests/test_query_deadline.py new file mode 100644 index 00000000..5a7030b2 --- /dev/null +++ b/backend/tests/test_query_deadline.py @@ -0,0 +1,402 @@ +"""查询时限要让数据库自己停下,而不只是后端不再等。 + +以前时限只有数据层的一道 asyncio.wait_for:到点取消、放弃等待,可数据库那边的语句 +照样在跑,占着连接池里的一个连接,直到它自己跑完(Oracle 实测多占了 60 秒); +aiosqlite 更糟,语句在线程里跑,取消之后关连接要排在它后面,一条不收敛的查询 +就能让这次调用永远回不来。 + +现在每条语句执行前先给数据库下一个语句级时限,到点由数据库自己中止: +- SQLite:进度回调(progress handler),超时后返回非零,SQLite 中止当前语句; +- PostgreSQL:SET LOCAL statement_timeout,只管这一个事务; +- MySQL:SET SESSION max_execution_time(MariaDB 是 max_statement_time),用完恢复; +- Oracle:驱动连接的 call_timeout,用完恢复。 + +SQLite 用真库测;另外三家用一个记账的假连接,断言下发的语句和参数。 +数据源还可以在 options.query_timeout_s 里按源配置时限,db_query 工具把它声明在 +metadata["timeout_s"] 上,引擎据此告诉界面上限是多少。 +""" +from __future__ import annotations + +import asyncio +import sqlite3 +import time +import types +import uuid +from contextlib import asynccontextmanager +from typing import Any + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import event, text + +from app.data import engine as data_engine +from app.data.engine import build_url, engines, run_query +from app.data.guard import QueryLimits, SqlRejected +from app.main import app + +#: 永远跑不完的查询:递归 CTE 没有终止条件。不靠机器快慢,时限不生效它就不会结束 +RUNAWAY = "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT count(*) FROM c" +#: 跑得完、但要走过成千上万条 SQLite 指令的查询:进度回调要是没摘掉,它会被立刻中止 +BUSY = ("WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 200000) " + "SELECT count(*) FROM c") + + +def _source(path, *, readonly: bool = True, **options: Any) -> Any: + return types.SimpleNamespace( + id=f"deadline_{uuid.uuid4().hex[:8]}", name="shop", kind="sqlite", database=str(path), + readonly=readonly, options=options, host=None, port=None, username=None, password=None, + description="", schema_cache={}, + ) + + +@pytest.fixture +def shop_db(tmp_path): + path = tmp_path / "shop.db" + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE orders (id INTEGER)") + conn.executemany("INSERT INTO orders VALUES (?)", [(i,) for i in range(5)]) + conn.commit() + conn.close() + return path + + +@pytest.fixture +async def sqlite_source(shop_db): + """真实的 SQLite 数据源。测完把还卡在语句上的连接打断,免得时限没生效时测试进程挂住。""" + made: list[Any] = [] + + async def make(**kw: Any) -> Any: + source = _source(shop_db, **kw) + engine = (await engines.get(source)).sync_engine + event.listen(engine, "connect", lambda dbapi_conn, _rec: raws.append(dbapi_conn)) + made.append(source) + return source + + raws: list[Any] = [] + yield make + for dbapi_conn in raws: + try: + dbapi_conn._connection._conn.interrupt() + except Exception: # noqa: BLE001 - 已经关掉的连接 + pass + for source in made: + await engines.invalidate(source.id) + + +async def _within(coro, seconds: float) -> tuple[bool, Any, float]: + """跑 coro,最多等 seconds 秒;超时不取消(取消本身也会卡住),只报没回来。""" + started = time.perf_counter() + task = asyncio.ensure_future(coro) + done, _ = await asyncio.wait({task}, timeout=seconds) + if task not in done: + task.add_done_callback(lambda f: f.cancelled() or f.exception()) + return False, None, time.perf_counter() - started + return True, task.exception() or task.result(), time.perf_counter() - started + + +# -------------------------------------------------------------------------- +# SQLite:真库 +# -------------------------------------------------------------------------- + + +async def test_sqlite_stops_a_runaway_query_at_the_limit(sqlite_source): + source = await sqlite_source() + back, outcome, took = await _within( + run_query(source, RUNAWAY, limits=QueryLimits(timeout_seconds=1)), 6) + assert back, "查询时限到了,语句还在数据库里跑,调用一直没回来" + assert isinstance(outcome, SqlRejected), outcome + assert "1s" in str(outcome) and "被中断" in str(outcome) + assert took < 3, f"1s 的时限等了 {took:.1f}s" + engine = await engines.get(source) + assert engine.sync_engine.pool.checkedout() == 0, "被中断的查询还占着连接" + + +async def test_the_limit_does_not_leak_onto_the_next_user_of_the_connection(sqlite_source): + source = await sqlite_source() + back, outcome, _ = await _within( + run_query(source, RUNAWAY, limits=QueryLimits(timeout_seconds=1)), 6) + assert back and isinstance(outcome, SqlRejected) + + # 同一个连接(池里只剩它)接着给别人用:时限早就过了,回调要是还挂着,这条会被立刻中止 + engine = await engines.get(source) + async with engine.connect() as conn: + assert (await conn.execute(text(BUSY))).scalar() == 200000 + result = await run_query(source, BUSY) + assert result.rows == [[200000]] + + +async def test_a_runaway_write_is_stopped_and_rolled_back(sqlite_source, shop_db): + source = await sqlite_source(readonly=False) + runaway_insert = ("INSERT INTO orders (id) WITH RECURSIVE c(x) AS " + "(SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT x FROM c") + back, outcome, took = await _within( + run_query(source, runaway_insert, limits=QueryLimits(timeout_seconds=1)), 6) + assert back, "写操作的时限到了,语句还在跑" + assert isinstance(outcome, SqlRejected) and "已回滚" in str(outcome), outcome + assert took < 3 + conn = sqlite3.connect(shop_db) + assert conn.execute("SELECT count(*) FROM orders").fetchone() == (5,), "被中断的写没有回滚" + conn.close() + + +async def test_a_source_can_set_its_own_limit(sqlite_source): + # 表单里填的是字符串 + source = await sqlite_source(query_timeout_s="1") + back, outcome, took = await _within(run_query(source, RUNAWAY), 6) + assert back and isinstance(outcome, SqlRejected), outcome + assert "1s" in str(outcome) and took < 3 + + +def test_the_limit_is_read_from_options_and_defaults_otherwise(shop_db): + from app.data.engine import query_timeout + + assert query_timeout(_source(shop_db)) == QueryLimits().timeout_seconds + assert query_timeout(_source(shop_db, query_timeout_s="45")) == 45 + assert query_timeout(_source(shop_db, query_timeout_s=2.5)) == 2.5 + # 库里存着的坏值不让查询跑不起来:退回缺省 + for bad in ("abc", "0", "-3", "", None, True, "99999"): + assert query_timeout(_source(shop_db, query_timeout_s=bad)) == QueryLimits().timeout_seconds, bad + + +def test_the_limit_is_not_passed_to_the_driver(shop_db): + source = types.SimpleNamespace( + id="x", name="shop", kind="mysql", host="db.example", port=3306, database="shop", + username="reader", password=None, readonly=True, + options={"charset": "utf8mb4", "query_timeout_s": "60", "schema": "sales"}) + url = build_url(source) + assert "charset=utf8mb4" in url + assert "query_timeout_s" not in url, "时限是 AgentLab 自己的配置,驱动不认识它" + + +async def test_the_query_tool_declares_the_limit(sqlite_source, monkeypatch): + from app.engine.toolcalls import limit_of + from app.tools.datasource import _make_query_tool + from app.tools.registry import ToolContext + + ctx = ToolContext(run_id="t", node_id="n") + default = _make_query_tool(await sqlite_source(), ctx) + assert default.metadata["timeout_s"] == QueryLimits().timeout_seconds + + tool = _make_query_tool(await sqlite_source(query_timeout_s="1"), ctx) + assert tool.metadata["timeout_s"] == 1 + limit = limit_of(tool, tool.name, {"sql": RUNAWAY}) + assert limit.seconds == 1 and limit.kind == "query" and limit.self_timed + + back, outcome, took = await _within(tool.coroutine(sql=RUNAWAY), 6) + assert back and "1s" in str(outcome) and took < 3, (outcome, took) + + +# -------------------------------------------------------------------------- +# PostgreSQL / MySQL / MariaDB / Oracle:假连接记账 +# -------------------------------------------------------------------------- + + +class _Result: + def __init__(self, rows: list[list[Any]]) -> None: + self._rows = rows + self.returns_rows = True + self.rowcount = len(rows) + + def keys(self) -> list[str]: + return ["n"] + + def fetchmany(self, n: int) -> list[list[Any]]: + return self._rows[:n] + + def __aiter__(self): + async def gen(): + for row in self._rows: + yield row + return gen() + + +class _FakeConn: + def __init__(self, log: list[str], driver: Any, mariadb: bool, fail: Exception | None) -> None: + self.log, self.driver, self.fail = log, driver, fail + self.dialect = types.SimpleNamespace(is_mariadb=mariadb) + + async def execute(self, clause): + sql = str(clause) + self.log.append(sql) + if sql.startswith("SET"): + return _Result([]) + if self.fail: + raise self.fail + return _Result([[1]]) + + async def stream(self, clause): + self.log.append(str(clause)) + self.log.append(f"call_timeout={getattr(self.driver, 'call_timeout', None)}") + if self.fail: + raise self.fail + return _Result([[1]]) + + async def get_raw_connection(self): + return types.SimpleNamespace(driver_connection=self.driver) + + +class _FakeEngine: + def __init__(self, *, mariadb: bool = False, fail: Exception | None = None) -> None: + self.log: list[str] = [] + self.driver = types.SimpleNamespace(call_timeout=0) + self.mariadb, self.fail = mariadb, fail + + @asynccontextmanager + async def connect(self): + yield _FakeConn(self.log, self.driver, self.mariadb, self.fail) + + begin = connect + + +def _fake(monkeypatch, **kw: Any) -> _FakeEngine: + fake = _FakeEngine(**kw) + + async def _get(_source): + return fake + + monkeypatch.setattr(engines, "get", _get) + return fake + + +def _remote(kind: str, *, readonly: bool = True) -> Any: + return types.SimpleNamespace(id=f"r_{kind}", name="shop", kind=kind, readonly=readonly, + options={}, database="shop") + + +async def test_postgres_gets_a_statement_timeout_for_this_transaction_only(monkeypatch): + fake = _fake(monkeypatch) + await run_query(_remote("postgres"), "SELECT 1", limits=QueryLimits(timeout_seconds=5)) + assert fake.log[:2] == ["SET LOCAL statement_timeout = 5000", "SELECT 1"], fake.log + # SET LOCAL 随事务回滚作废,不用也不该再发一条恢复 + assert not [s for s in fake.log[2:] if s.startswith("SET")] + + +async def test_postgres_writes_are_bounded_too(monkeypatch): + fake = _fake(monkeypatch) + await run_query(_remote("postgresql", readonly=False), "UPDATE orders SET id = 1", + limits=QueryLimits(timeout_seconds=5)) + assert fake.log[:2] == ["SET LOCAL statement_timeout = 5000", "UPDATE orders SET id = 1"] + + +async def test_mysql_gets_max_execution_time_and_it_is_put_back(monkeypatch): + fake = _fake(monkeypatch) + await run_query(_remote("mysql"), "SELECT 1", limits=QueryLimits(timeout_seconds=5)) + assert fake.log[0] == "SET SESSION max_execution_time = 5000" + assert "SELECT 1" in fake.log + # 会话级设置跟着连接回池子:用完恢复成服务器的缺省,探查结构这类长操作不受它牵连 + assert fake.log[-1] == "SET SESSION max_execution_time = DEFAULT" + + +async def test_mariadb_uses_its_own_variable_in_seconds(monkeypatch): + fake = _fake(monkeypatch, mariadb=True) + await run_query(_remote("mysql"), "SELECT 1", limits=QueryLimits(timeout_seconds=5)) + assert fake.log[0] == "SET SESSION max_statement_time = 5" + assert fake.log[-1] == "SET SESSION max_statement_time = DEFAULT" + + +async def test_oracle_gets_a_call_timeout_and_it_is_put_back(monkeypatch): + fake = _fake(monkeypatch) + await run_query(_remote("oracle"), "SELECT 1 FROM DUAL", limits=QueryLimits(timeout_seconds=5)) + assert "call_timeout=5000" in fake.log, fake.log + assert fake.driver.call_timeout == 0, "call_timeout 跟着连接回池子,探查结构时也会被它掐" + + +@pytest.mark.parametrize("kind, driver_error", [ + ("postgres", "canceling statement due to statement timeout"), + ("mysql", "(3024, 'Query execution was interrupted, maximum statement execution time exceeded')"), + ("mariadb", "(1969, 'Query execution was interrupted (max_statement_time exceeded)')"), + ("oracle", "DPY-4024: call timeout of 5000 ms exceeded"), +]) +async def test_a_query_stopped_by_the_database_reads_as_a_timeout(monkeypatch, kind, driver_error): + _fake(monkeypatch, mariadb=kind == "mariadb", fail=RuntimeError(driver_error)) + with pytest.raises(SqlRejected) as caught: + await run_query(_remote(kind), "SELECT 1", limits=QueryLimits(timeout_seconds=5)) + assert "查询超过 5s 被中断" in str(caught.value), caught.value + + +class _PgCanceled(Exception): + """asyncpg 的 QueryCanceledError:错误码在 sqlstate 上,文字跟着服务器的语言走。""" + + sqlstate = "57014" + + +class _Wrapped(Exception): + """SQLAlchemy 包过一层的驱动异常:原始异常在 .orig 上。""" + + def __init__(self, orig: Exception) -> None: + super().__init__(f"(wrapped) {type(orig).__name__}") + self.orig = orig + + +@pytest.mark.parametrize("kind, error", [ + # 中文环境的 PostgreSQL(真库实测就是这句):按英文原文一个也认不出来,得认错误码 + ("postgres", _PgCanceled("由于语句执行超时,正在取消查询命令")), + ("postgres", _Wrapped(_PgCanceled("由于语句执行超时,正在取消查询命令"))), + ("mysql", RuntimeError(3024, "查询执行被中断,超过了最长执行时间")), + ("mariadb", _Wrapped(RuntimeError(1969, "查询执行被中断"))), +]) +async def test_a_localized_timeout_is_recognised_by_its_code(monkeypatch, kind, error): + _fake(monkeypatch, mariadb=kind == "mariadb", fail=error) + with pytest.raises(SqlRejected) as caught: + await run_query(_remote(kind), "SELECT 1", limits=QueryLimits(timeout_seconds=5)) + assert "查询超过 5s 被中断" in str(caught.value), caught.value + + +async def test_other_errors_are_not_mistaken_for_a_timeout(monkeypatch): + _fake(monkeypatch, fail=RuntimeError('relation "orderz" does not exist')) + with pytest.raises(RuntimeError, match="orderz"): + await run_query(_remote("postgres"), "SELECT 1", limits=QueryLimits(timeout_seconds=5)) + + +# -------------------------------------------------------------------------- +# 接口:按源配置的时限 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def test_the_form_offers_the_limit_for_every_kind(client): + kinds = (await client.get("/api/datasources/kinds")).json() + for kind in kinds["kinds"]: + field = next((a for a in kind["advanced"] if a["key"] == "query_timeout_s"), None) + assert field and "秒" in field["label"], kind["value"] + + +@pytest.mark.parametrize("bad", ["abc", "0", "-5", "100000"]) +async def test_a_bad_limit_is_refused_in_words(client, shop_db, bad): + r = await client.post("/api/datasources", json={ + "name": f"shop_{uuid.uuid4().hex[:6]}", "kind": "sqlite", "database": str(shop_db), + "options": {"query_timeout_s": bad}}) + assert r.status_code == 422, r.text + detail = r.json()["detail"] + assert isinstance(detail, str) and "查询时限" in detail, detail + + +async def test_a_good_limit_is_saved_and_can_be_changed(client, shop_db): + r = await client.post("/api/datasources", json={ + "name": f"shop_{uuid.uuid4().hex[:6]}", "kind": "sqlite", "database": str(shop_db), + "options": {"query_timeout_s": "45"}}) + assert r.status_code == 201, r.text + row = r.json() + try: + r = await client.patch(f"/api/datasources/{row['id']}", + json={"options": {"query_timeout_s": "nope"}}) + assert r.status_code == 422 + r = await client.patch(f"/api/datasources/{row['id']}", + json={"options": {"query_timeout_s": "90"}}) + assert r.status_code == 200 and r.json()["options"]["query_timeout_s"] == "90" + finally: + await client.delete(f"/api/datasources/{row['id']}") + + +def test_the_data_layer_backstop_stays_inside_the_engine_grace(): + """数据库按时限停下之后,数据层自己的兜底要比引擎放弃得早:引擎放弃时只撒手、 + 不取消(见 toolcalls.Limit.self_timed),数据层这句「超过 Ns 被中断」才是给人看的那句。""" + from app.engine.toolcalls import GRACE_S + + assert 0 < data_engine.BACKSTOP_S < GRACE_S diff --git a/backend/tests/test_replay_side_effects.py b/backend/tests/test_replay_side_effects.py new file mode 100644 index 00000000..43965e6a --- /dev/null +++ b/backend/tests/test_replay_side_effects.py @@ -0,0 +1,317 @@ +"""审批恢复之后,节点从头重放;已经执行过的工具不能再执行一次,也不能在轨迹里再"执行"一次。 + +LangGraph 恢复一个停在 interrupt 上的节点,是把整个节点函数从头再跑一遍, +interrupt() 按出现的顺序返回已经给过的答复。节点里一连串的「问人 → 调工具」 +于是每恢复一次就重放一遍前面所有的步骤: + +- 工具真的再执行一次,副作用就翻倍(写库、发请求、沙箱里改文件); +- 就算结果取自缓存,重放时照样发一遍 human.requested / human.resolved / + tool.start / tool.end,时间线和审计轨迹里同一次调用出现两回——运行 fac480ff + 的轨迹里 python_exec 就这么「执行了两次」,排查的人只能照字面理解。 + +这里用一个带副作用计数的工具,在同一个节点里连续审批两次、恢复两次。 +""" +from __future__ import annotations + +import asyncio +import json + +import pytest +from langchain_core.messages import AIMessage, ToolMessage +from pydantic import BaseModel +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.runner import run_manager +from app.tools.registry import register + +#: 真实执行次数。注册表是进程级的,所以工具只注册一次,计数按测试清零 +CALLS: list[dict] = [] + + +class _TallyArgs(BaseModel): + label: str + + +@register("tally_side_effect", "测试用:每执行一次记一笔", "test", _TallyArgs, + dangerous=True, needs_context=False) +async def _tally(label: str) -> str: + CALLS.append({"label": label}) + return json.dumps({"label": label, "n": len(CALLS)}) + + +@pytest.fixture(autouse=True) +async def engine_up(): + CALLS.clear() + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), + "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run: + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row and row.status in statuses: + return row + await asyncio.sleep(0.05) + raise AssertionError(f"等超时了,run {run_id} 没有进入 {statuses}") + + +async def _events(run_id: str) -> list[RunEvent]: + async with SessionLocal() as session: + return list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq) + )).scalars()) + + +def _script_two_calls(monkeypatch) -> None: + """第一轮调一次、看到结果再调一次、看到两次结果就收口。""" + from app.providers import mock_model + + def _decide(self, messages): + done = sum(isinstance(m, ToolMessage) for m in messages) + if self.tools and done < 2: + return AIMessage(content="", tool_calls=[ + {"name": "tally_side_effect", "args": {"label": f"第{done + 1}次"}, + "id": f"call_{done + 1}"}]) + return AIMessage(content="两次都记好了。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + + +async def _approve_until_done(run_id: str, times: int) -> Run: + for _ in range(times): + run = await _wait(run_id, ("interrupted", "failed", "succeeded")) + assert run.status == "interrupted", f"还没审批就结束了:{run.status} {run.error}" + await run_manager.resume(run_id, {"approved": True}) + return await _wait(run_id, ("failed", "succeeded")) + + +async def test_an_agent_that_asks_twice_runs_each_tool_exactly_once(monkeypatch): + _script_two_calls(monkeypatch) + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("bot", "agent", prompt="记两笔", tools=["tally_side_effect"], + approval="always", max_steps=5), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.bot.text }}"}]), + ) + run = await run_manager.start(graph=graph, input_payload={"question": "记两笔"}) + final = await _approve_until_done(run.id, 2) + + assert final.status == "succeeded", final.error + assert [c["label"] for c in CALLS] == ["第1次", "第2次"], f"副作用翻倍了:{CALLS}" + + events = await _events(run.id) + starts = [e.data["call_id"] for e in events if e.type == "tool.start"] + ends = [e.data["call_id"] for e in events if e.type == "tool.end"] + # 每次调用在轨迹里只出现一回:重放取的是缓存,不是又执行了一次 + assert starts == ["call_1", "call_2"], starts + assert ends == ["call_1", "call_2"], ends + asked = [e.data.get("tool") for e in events if e.type == "human.requested"] + answered = [e.data.get("approved") for e in events if e.type == "human.resolved"] + assert asked == ["tally_side_effect", "tally_side_effect"], asked + assert answered == [True, True], answered + + +async def test_parallel_tool_calls_after_one_approval_are_not_replayed(monkeypatch): + """一轮里两个调用、各要审批一次:第二次恢复时第一个已经跑过的不能再跑。""" + from app.providers import mock_model + + def _decide(self, messages): + if self.tools and not any(isinstance(m, ToolMessage) for m in messages): + return AIMessage(content="", tool_calls=[ + {"name": "tally_side_effect", "args": {"label": "甲"}, "id": "call_a"}, + {"name": "tally_side_effect", "args": {"label": "乙"}, "id": "call_b"}, + ]) + return AIMessage(content="都记了。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("bot", "agent", prompt="记两笔", tools=["tally_side_effect"], + approval="always", parallel_tools=True, max_steps=3), + node("out", "output", fields=[]), + ) + run = await run_manager.start(graph=graph, input_payload={"question": "记"}) + final = await _approve_until_done(run.id, 2) + + assert final.status == "succeeded", final.error + assert sorted(c["label"] for c in CALLS) == ["乙", "甲"], CALLS + starts = [e.data["call_id"] for e in await _events(run.id) if e.type == "tool.start"] + assert starts == ["call_a", "call_b"], starts + + +async def test_a_tool_node_asks_once_in_the_trail(): + """工具节点恢复时整个节点重放:请求审批那条以前会再发一遍,时间线上像是又问了一次。""" + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("t", "tool", tool="tally_side_effect", args={"label": "一次"}, approval="always"), + node("out", "output", fields=[]), + ) + run = await run_manager.start(graph=graph, input_payload={"question": "x"}) + final = await _approve_until_done(run.id, 1) + assert final.status == "succeeded", final.error + assert len(CALLS) == 1 + + kinds = [e.type for e in await _events(run.id) if e.node_id == "t"] + assert kinds.count("human.requested") == 1, kinds + assert kinds.count("human.resolved") == 1, kinds + assert kinds.count("tool.start") == 1, kinds + + +async def test_a_human_node_is_requested_once(): + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("h", "human", mode="approve", title="看一眼"), + node("out", "output", fields=[]), + ) + run = await run_manager.start(graph=graph, input_payload={"question": "x"}) + final = await _approve_until_done(run.id, 1) + assert final.status == "succeeded", final.error + kinds = [e.type for e in await _events(run.id) if e.node_id == "h"] + assert kinds.count("human.requested") == 1, kinds + assert kinds.count("human.resolved") == 1, kinds + + +async def test_continuing_after_a_failure_does_not_repeat_the_tool_in_the_trail(monkeypatch): + """模型调用在工具之后挂了,接着跑:工具结果取自断点,轨迹里也不能再出现一次执行。""" + from app.providers import mock_model + + state = {"fail": True} + + def _decide(self, messages): + if self.tools and not any(isinstance(m, ToolMessage) for m in messages): + return AIMessage(content="", tool_calls=[ + {"name": "tally_side_effect", "args": {"label": "只一次"}, "id": "call_once"}]) + if state["fail"]: + raise RuntimeError("供应商那边断了") + return AIMessage(content="好了。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("bot", "agent", prompt="记一笔", tools=["tally_side_effect"], approval="never", + max_steps=3), + node("out", "output", fields=[]), + ) + run = await run_manager.start(graph=graph, input_payload={"question": "x"}) + first = await _wait(run.id, ("failed", "succeeded")) + assert first.status == "failed" + + state["fail"] = False + await run_manager.continue_failed(run.id) + final = await _wait(run.id, ("failed", "succeeded")) + assert final.status == "succeeded", final.error + assert len(CALLS) == 1 + starts = [e.data["call_id"] for e in await _events(run.id) if e.type == "tool.start"] + assert starts == ["call_once"], starts + + +# -------------------------------------------------------------------------- +# 升级前停下的断点 +# +# LangGraph 认 task 缓存靠「函数名 + 节点里第几次调用」。这一版多了 once / ask 的记录 +# task、tool_step 也换了位置:旧版停下的断点照新代码重放,节点里已经执行过的工具对不上 +# 旧位置,会再执行一次。checkpoint 的 metadata 里记着重放协议版本,对不上又有这种风险 +# 时拒绝恢复。这里把「旧版」模拟成协议版本低一号写下的断点。 +# -------------------------------------------------------------------------- + + +def _older_protocol(monkeypatch) -> None: + from app.engine import runner + + monkeypatch.setattr(runner, "PROTOCOL", runner.PROTOCOL - 1) + + +def _upgrade(monkeypatch) -> None: + from app.engine import replay, runner + + monkeypatch.setattr(runner, "PROTOCOL", replay.PROTOCOL) + + +async def test_an_old_pause_after_tools_ran_is_refused(monkeypatch): + _script_two_calls(monkeypatch) + _older_protocol(monkeypatch) + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("bot", "agent", prompt="记两笔", tools=["tally_side_effect"], + approval="always", max_steps=5), + node("out", "output", fields=[]), + ) + run = await run_manager.start(graph=graph, input_payload={"question": "记两笔"}) + await _wait(run.id, ("interrupted",)) + await run_manager.wait_idle(run.id) + await run_manager.resume(run.id, {"approved": True}) # 第一笔:旧版里批的、执行的 + await asyncio.sleep(0.1) + await _wait(run.id, ("interrupted",)) + await run_manager.wait_idle(run.id) + assert len(CALLS) == 1 + + _upgrade(monkeypatch) + with pytest.raises(ValueError, match="再执行一次"): + await run_manager.resume(run.id, {"approved": True}) + assert len(CALLS) == 1 + async with SessionLocal() as session: + row = await session.get(Run, run.id) + assert row.status == "interrupted", "拒绝恢复不能动运行的状态:它还可以被放弃" + assert await run_manager.cancel(run.id) == "cancelled" + + +async def test_an_old_pause_before_anything_ran_still_resumes(monkeypatch): + """停在第一次审批上、之前什么都没做:重放只会多发一条 human.requested,照常放行。""" + _script_two_calls(monkeypatch) + _older_protocol(monkeypatch) + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("bot", "agent", prompt="记两笔", tools=["tally_side_effect"], + approval="always", max_steps=5), + node("out", "output", fields=[]), + ) + run = await run_manager.start(graph=graph, input_payload={"question": "记两笔"}) + await _wait(run.id, ("interrupted",)) + await run_manager.wait_idle(run.id) + + _upgrade(monkeypatch) + final = await _approve_until_done(run.id, 2) + assert final.status == "succeeded", final.error + assert [c["label"] for c in CALLS] == ["第1次", "第2次"] + + +async def test_continuing_an_old_failure_after_tools_ran_is_refused(monkeypatch): + from app.providers import mock_model + + def _decide(self, messages): + if self.tools and not any(isinstance(m, ToolMessage) for m in messages): + return AIMessage(content="", tool_calls=[ + {"name": "tally_side_effect", "args": {"label": "只一次"}, "id": "call_once"}]) + raise RuntimeError("供应商那边断了") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + _older_protocol(monkeypatch) + graph = chain( + node("start", "input", fields=[{"name": "question"}]), + node("bot", "agent", prompt="记一笔", tools=["tally_side_effect"], approval="never", + max_steps=3), + node("out", "output", fields=[]), + ) + run = await run_manager.start(graph=graph, input_payload={"question": "x"}) + assert (await _wait(run.id, ("failed", "succeeded"))).status == "failed" + await run_manager.wait_idle(run.id) + + _upgrade(monkeypatch) + with pytest.raises(ValueError, match="再执行一次"): + await run_manager.continue_failed(run.id) + assert len(CALLS) == 1 diff --git a/backend/tests/test_run_races.py b/backend/tests/test_run_races.py new file mode 100644 index 00000000..b8172d5c --- /dev/null +++ b/backend/tests/test_run_races.py @@ -0,0 +1,288 @@ +"""运行收尾和几条并发路径上的竞态。 + +终态事件和运行记录要同时可见。两边都有人在读: +- 收到 run.finished 的客户端马上 GET 这次运行取完整成果(事件里的 output 是截断的); + 以前先落事件、隔一个事务才提交状态,每次都读到 running 和空的 output,问数据页 + 于是把截断过的成果标成残缺存进会话; +- 读到 failed 的一方马上去翻事件,要翻得到 run.failed。更早以前是先提交状态,这一边 + 偶尔翻不到。 +两边都要成立,只能放进同一个事务。 + +恢复 / 接着跑和「放弃这次运行」抢同一次运行:读到 interrupted 之后隔了好几次 await +才写 running,放弃正好在这中间提交的话,一次已经取消、封存、发过 run.cancelled 的 +运行会被改回 running,又拉起来跑。 + +刚停到审批上、还在落 interrupted 状态的那一刻点「放弃」:以前直接取消执行任务,把 +收尾从中间打断——状态停在 running、审批还挂着、任务没了,再点一次只剩 409。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import select + +from app.core.bus import bus +from app.db.base import SessionLocal +from app.db.models import Approval, Run, RunEvent +from app.engine import compiler, runner +from app.engine.context import NodeError +from app.engine.runner import RunManager, run_manager +from app.engine.schema import NodeType + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), + "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +@pytest.fixture +def slow(monkeypatch): + """transform 节点慢一点,好让订阅先接上;id 为 boom 的直接失败,id 为 stuck 的一直卡着。""" + original = compiler.RUNNERS[NodeType.TRANSFORM] + + async def run(state, ctx): + await asyncio.sleep(30 if ctx.node.id == "stuck" else 0.15) + if ctx.node.id == "boom": + raise NodeError(ctx.node.id, "炸了") + return await original(state, ctx) + + monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, run) + + +def _graph(work_id: str = "work", *, gate: bool = False) -> dict: + nodes = [node("start", "input", fields=[{"name": "q"}]), + node(work_id, "transform", mode="template", template="成果全文")] + if gate: + nodes.append(node("gate", "human", mode="approve", title="放行吗")) + nodes.append(node("out", "output", fields=[{"name": "r", "value": f"{{{{ nodes.{work_id} }}}}"}])) + return chain(*nodes) + + +async def _status(run_id: str) -> str: + async with SessionLocal() as session: + return (await session.get(Run, run_id)).status + + +async def _until(check, timeout: float = 15.0) -> None: + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + if await check(): + return + await asyncio.sleep(0.02) + raise AssertionError("等超时了") + + +async def _events(run_id: str) -> list[RunEvent]: + async with SessionLocal() as session: + return list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq) + )).scalars()) + + +async def _row_when(run_id: str, terminal: str, act=None) -> Run: + """订阅这次运行,一收到 terminal 事件就读运行记录——客户端就是这么做的。""" + sub = bus.attach(run_id) + acting = asyncio.ensure_future(act()) if act is not None else None + try: + async for event in sub.stream(): + if str(event.type) == terminal: + async with SessionLocal() as session: + return await session.get(Run, run_id) + finally: + sub.close() + if acting is not None: + await acting + raise AssertionError(f"没等到 {terminal}") + + +# -------------------------------------------------------------------------- +# 终态事件和运行记录同时可见 +# -------------------------------------------------------------------------- + + +async def test_the_run_is_final_when_run_finished_arrives(slow): + for _ in range(5): + run = await run_manager.start(graph=_graph(), input_payload={"q": "x"}) + row = await _row_when(run.id, "run.finished") + assert row.status == "succeeded", f"收到 run.finished 时运行还是 {row.status}" + assert row.output == {"r": "成果全文"}, row.output + assert row.finished_at is not None and row.usage.get("wall_ms") is not None + await run_manager.wait_idle(run.id) + + +async def test_the_run_is_final_when_run_failed_arrives(slow): + for _ in range(5): + run = await run_manager.start(graph=_graph("boom"), input_payload={"q": "x"}) + row = await _row_when(run.id, "run.failed") + assert row.status == "failed" and "炸了" in (row.error or ""), (row.status, row.error) + assert row.error_node_id == "boom" + await run_manager.wait_idle(run.id) + + +async def test_the_terminal_event_is_there_once_the_status_is(slow): + """反方向:读到终态状态时,终态事件一定已经在库里。""" + for work_id, terminal in (("work", "run.finished"), ("boom", "run.failed")): + for _ in range(3): + run = await run_manager.start(graph=_graph(work_id), input_payload={"q": "x"}) + + async def settled(run_id=run.id) -> bool: + return await _status(run_id) in ("succeeded", "failed") + + await _until(settled) + assert (await _events(run.id))[-1].type == terminal + await run_manager.wait_idle(run.id) + + +async def test_the_run_is_final_when_a_stop_arrives(slow): + run = await run_manager.start(graph=_graph("stuck"), input_payload={"q": "x"}) + await _until(lambda: _started(run.id, "stuck")) + row = await _row_when(run.id, "run.cancelled", act=lambda: run_manager.cancel(run.id)) + assert row.status == "cancelled" and row.finished_at is not None + + +async def test_the_run_is_final_when_an_abandon_arrives(slow): + run = await run_manager.start(graph=_graph(gate=True), input_payload={"q": "x"}) + await _until(lambda: _is(run.id, "interrupted")) + await run_manager.wait_idle(run.id) + row = await _row_when(run.id, "run.cancelled", act=lambda: run_manager.cancel(run.id)) + assert row.status == "cancelled" and row.finished_at is not None + assert row.last_seq == (await _events(run.id))[-1].seq + + +async def _started(run_id: str, node_id: str) -> bool: + return any(e.type == "node.started" and e.node_id == node_id for e in await _events(run_id)) + + +async def _is(run_id: str, status: str) -> bool: + return await _status(run_id) == status + + +# -------------------------------------------------------------------------- +# 恢复 / 接着跑 和 放弃 抢同一次运行 +# -------------------------------------------------------------------------- + + +async def _suspended_run() -> str: + """服务重启挂起的运行:interrupted,没有待审批,断点完好。""" + run = await run_manager.start(graph=_graph("stuck"), input_payload={"q": "x"}) + await _until(lambda: _started(run.id, "stuck")) + await run_manager.shutdown() + await run_manager.setup() + assert await _status(run.id) == "interrupted" + return run.id + + +def _abandon_in_between(monkeypatch) -> None: + """在「读到 interrupted」和「写 running」之间,放弃这次运行的请求先提交了。""" + async def racing(run_id, spec, snapshot): + assert await run_manager._abandon(run_id, "另一个人") + return None + + monkeypatch.setattr(runner, "_stale_replay", racing) + + +@pytest.mark.parametrize("path", ["resume", "continue"]) +async def test_a_run_abandoned_mid_way_is_not_revived(slow, monkeypatch, path): + run_id = await _suspended_run() + _abandon_in_between(monkeypatch) + with pytest.raises(ValueError, match="已取消"): + if path == "resume": + await run_manager.resume(run_id, None) + else: + await run_manager.continue_failed(run_id) + assert not run_manager.is_active(run_id), "取消了的运行又被拉起来跑了" + assert await _status(run_id) == "cancelled" + events = await _events(run_id) + assert events[-1].type == "run.cancelled" and not [e for e in events if e.type == "run.resumed"] + + +async def test_an_approval_closed_by_an_abandon_is_not_answered_again(slow, monkeypatch): + run = await run_manager.start(graph=_graph(gate=True), input_payload={"q": "x"}) + await _until(lambda: _is(run.id, "interrupted")) + await run_manager.wait_idle(run.id) + _abandon_in_between(monkeypatch) + with pytest.raises(ValueError): + await run_manager.resume(run.id, {"approved": True}) + async with SessionLocal() as session: + approval = (await session.execute( + select(Approval).where(Approval.run_id == run.id))).scalar_one() + assert approval.status == "cancelled", approval.status + assert await _status(run.id) == "cancelled" and not run_manager.is_active(run.id) + + +# -------------------------------------------------------------------------- +# 刚停到审批上、还在落状态时点「放弃」 +# -------------------------------------------------------------------------- + + +async def test_abandoning_while_the_pause_is_being_recorded(slow, monkeypatch): + original = RunManager._finalize + recording = asyncio.Event() + + async def slow_finalize(self, run_id, status, *args, **kwargs): + if status == "interrupted": + recording.set() + await asyncio.sleep(0.4) # 落 interrupted 状态的这一段被拉长 + return await original(self, run_id, status, *args, **kwargs) + + monkeypatch.setattr(RunManager, "_finalize", slow_finalize) + run = await run_manager.start(graph=_graph(gate=True), input_payload={"q": "x"}) + await asyncio.wait_for(recording.wait(), 10) + assert run.id in run_manager._finalizing + + assert await run_manager.cancel(run.id, actor="审核员") == "cancelled" + assert await _status(run.id) == "cancelled" + async with SessionLocal() as session: + approval = (await session.execute( + select(Approval).where(Approval.run_id == run.id))).scalar_one() + assert approval.status == "cancelled" and approval.resolved_by == "审核员" + assert (await _events(run.id))[-1].type == "run.cancelled" + + +async def test_an_approval_closed_just_before_it_is_marked_answered(slow, monkeypatch): + """放弃恰好提交在「读到这条审批还是 pending」和「把它记成已回复」之间。""" + import sqlite3 + + from sqlalchemy import event + + from app.db.base import engine + + run = await run_manager.start(graph=_graph(gate=True), input_payload={"q": "x"}) + await _until(lambda: _is(run.id, "interrupted")) + await run_manager.wait_idle(run.id) + armed = {"on": True} + + def abandon_first(conn, cursor, statement, params, context, executemany): + if armed["on"] and statement.lstrip().upper().startswith("UPDATE APPROVALS"): + armed["on"] = False + other = sqlite3.connect(engine.url.database, timeout=5) + other.execute("UPDATE approvals SET status='cancelled' WHERE run_id=?", (run.id,)) + other.execute("UPDATE runs SET status='cancelled' WHERE id=?", (run.id,)) + other.commit() + other.close() + + event.listen(engine.sync_engine, "before_cursor_execute", abandon_first) + try: + with pytest.raises(ValueError, match="已经处理过了|放弃"): + await run_manager.resume(run.id, {"approved": True}) + finally: + event.remove(engine.sync_engine, "before_cursor_execute", abandon_first) + assert not armed["on"] + async with SessionLocal() as session: + approval = (await session.execute( + select(Approval).where(Approval.run_id == run.id))).scalar_one() + assert approval.status == "cancelled", "关掉的审批被改回了已回复" + assert not run_manager.is_active(run.id) diff --git a/backend/tests/test_runs_cancel_waiting.py b/backend/tests/test_runs_cancel_waiting.py new file mode 100644 index 00000000..91b07dde --- /dev/null +++ b/backend/tests/test_runs_cancel_waiting.py @@ -0,0 +1,245 @@ +"""停在审批上的运行要能放弃;记录页要能只看「没保存的工作流」跑出来的运行。 + +放弃:等审批的运行此时没有执行任务,以前 POST /cancel 只会去取消任务,于是一律 +回 409「已经不在执行中」。可它明明还挂着一条待审批——界面上右栏的「去审批」旁边 +给不出「放弃这次运行」,审批卡留在全局待办里,只能批了它、让它跑完。服务重启 +挂起的运行(interrupted、没有待审批)同样没法收掉。 + +真正的取消:状态置为 cancelled,待审批一并关掉(不再出现在待办里,也不能再批), +发 run.cancelled 并封存;在线的客户端收到结束标记。 +""" +from __future__ import annotations + +import asyncio +from urllib.parse import quote + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Approval, Run, RunEvent, Workflow +from app.engine.runner import run_manager +from app.main import app + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +GATED = { + "nodes": [ + node("start", "input", fields=[{"name": "question"}]), + node("gate", "human", mode="approve", title="放行吗"), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.gate.approved }}"}]), + ], + "edges": [{"source": "start", "target": "gate"}, {"source": "gate", "target": "out"}], +} + + +async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 15.0) -> Run: + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row and row.status in statuses: + return row + await asyncio.sleep(0.05) + raise AssertionError(f"run {run_id} 没有进入 {statuses}") + + +async def _waiting_run() -> str: + run = await run_manager.start(graph=GATED, input_payload={"question": "x"}) + await _wait(run.id, ("interrupted",)) + await run_manager.wait_idle(run.id) + return run.id + + +async def _events(run_id: str) -> list[RunEvent]: + async with SessionLocal() as session: + return list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq) + )).scalars()) + + +async def test_a_run_waiting_for_approval_can_be_abandoned(client): + run_id = await _waiting_run() + r = await client.post(f"/api/runs/{run_id}/cancel", headers={"X-Actor": quote("审核员甲")}) + assert r.status_code == 200, r.text + assert r.json()["status"] == "cancelled" + + row = (await client.get(f"/api/runs/{run_id}")).json() + assert row["status"] == "cancelled" + assert row["finished_at"] and row["manifest_seq"], "取消是终态:要收尾时间,也要封存" + assert row["usage"]["wait_ms"] >= 0 + + # 待审批一并关掉:不再出现在待办里,谁关的记下来 + assert (await client.get("/api/approvals", params={"status": "pending", + "run_id": run_id})).json() == [] + async with SessionLocal() as session: + approval = (await session.execute( + select(Approval).where(Approval.run_id == run_id))).scalar_one() + assert approval.status == "cancelled" + assert approval.resolved_by == "审核员甲" and approval.resolved_at is not None + + events = await _events(run_id) + last = events[-1] + assert last.type == "run.cancelled", [e.type for e in events[-3:]] + assert set(last.data["timing"]) == {"wall_ms", "active_ms", "wait_ms"} + assert last.data.get("actor") == "审核员甲" + verified = (await client.get(f"/api/runs/{run_id}/verify")).json() + assert verified["sealed"] and verified["ok"], verified + + +async def test_an_abandoned_run_cannot_be_approved_or_continued(client): + run_id = await _waiting_run() + assert (await client.post(f"/api/runs/{run_id}/cancel")).status_code == 200 + async with SessionLocal() as session: + approval = (await session.execute( + select(Approval).where(Approval.run_id == run_id))).scalar_one() + + r = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": True}) + assert r.status_code == 409 + r = await client.post(f"/api/runs/{run_id}/resume", json={"response": True}) + assert r.status_code == 409 + r = await client.post(f"/api/runs/{run_id}/continue", json={}) + assert r.status_code == 409 and "已取消" in r.json()["detail"] + # 再取消一次是 409:已经是终态了 + r = await client.post(f"/api/runs/{run_id}/cancel") + assert r.status_code == 409 + + +async def test_a_run_suspended_by_a_restart_can_be_abandoned_too(client): + async with SessionLocal() as session: + run = Run(workflow_name="挂起", status="interrupted", graph=GATED, input={}, + error="服务重启,运行已挂起,可从断点恢复") + session.add(run) + await session.commit() + run_id = run.id + r = await client.post(f"/api/runs/{run_id}/cancel") + assert r.status_code == 200, r.text + row = (await client.get(f"/api/runs/{run_id}")).json() + assert row["status"] == "cancelled" + assert (await _events(run_id))[-1].type == "run.cancelled" + + +async def test_finished_runs_still_refuse_to_be_cancelled(client): + async with SessionLocal() as session: + run = Run(workflow_name="完了", status="succeeded", graph=GATED, input={}) + session.add(run) + await session.commit() + run_id = run.id + r = await client.post(f"/api/runs/{run_id}/cancel") + assert r.status_code == 409 + assert "已完成" in r.json()["detail"] + assert (await client.post("/api/runs/nope/cancel")).status_code == 404 + + +class _Socket: + """够 stream_run 用的最小 WebSocket:记下发出去的帧。""" + + def __init__(self) -> None: + self.query_params: dict[str, str] = {} + self.sent: list[dict] = [] + self.closed = asyncio.Event() + + async def accept(self) -> None: + pass + + async def send_json(self, frame: dict) -> None: + self.sent.append(frame) + + async def receive_text(self) -> str: + await self.closed.wait() + from starlette.websockets import WebSocketDisconnect + + raise WebSocketDisconnect() + + async def close(self) -> None: + self.closed.set() + + +async def test_a_live_stream_ends_when_the_waiting_run_is_abandoned(): + """停在审批上时在线的连接不关(它在等恢复)。放弃之后它要收到 run.cancelled 和结束标记。""" + from app.api.runs import stream_run + + run_id = await _waiting_run() + async with SessionLocal() as session: + # 让连接以为运行还在进行:审批停下时在线的客户端就是这个处境 + (await session.get(Run, run_id)).status = "running" + await session.commit() + socket = _Socket() + listening = asyncio.create_task(stream_run(socket, run_id)) + await asyncio.sleep(0.2) + async with SessionLocal() as session: + (await session.get(Run, run_id)).status = "interrupted" + await session.commit() + + assert await run_manager.cancel(run_id) == "cancelled" + await asyncio.wait_for(listening, 5) + types = [f.get("type") for f in socket.sent] + assert "run.cancelled" in types, types + assert socket.sent[-1]["type"] == "stream.end" + assert socket.sent[-1]["data"]["status"] == "cancelled" + + +# -------------------------------------------------------------------------- +# 记录页:只看没保存的工作流跑出来的运行 +# -------------------------------------------------------------------------- + + +async def test_runs_can_be_filtered_to_unsaved_workflows(client): + async with SessionLocal() as session: + workflow = Workflow(name="已保存的", graph=GATED) + session.add(workflow) + await session.flush() + saved = Run(workflow_id=workflow.id, workflow_name="已保存的", graph=GATED, input={}, + status="succeeded") + loose = Run(workflow_id=None, workflow_name="临时图", graph=GATED, input={}, + status="succeeded") + session.add_all([saved, loose]) + await session.commit() + saved_id, loose_id = saved.id, loose.id + + ids = [r["id"] for r in (await client.get( + "/api/runs", params={"workflow_id": "__none__", "limit": 200})).json()] + assert loose_id in ids and saved_id not in ids + assert all(r["workflow_id"] is None for r in (await client.get( + "/api/runs", params={"workflow_id": "__none__"})).json()) + + +async def test_stopping_a_running_run_records_who_stopped_it(client, monkeypatch): + from app.engine import compiler + from app.engine.schema import NodeType + + async def slow(state, ctx): + await asyncio.sleep(5) + return {"nodes": {ctx.node.id: "x"}} + + monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, slow) + graph = {"nodes": [node("start", "input"), node("work", "transform", mode="template", template="x")], + "edges": [{"source": "start", "target": "work"}]} + run = await run_manager.start(graph=graph, input_payload={}) + await _wait(run.id, ("running",)) + await asyncio.sleep(0.2) + r = await client.post(f"/api/runs/{run.id}/cancel", headers={"X-Actor": quote("值班员")}) + assert r.json()["status"] == "stopping" + await _wait(run.id, ("cancelled",)) + await run_manager.wait_idle(run.id) + last = (await _events(run.id))[-1] + assert last.type == "run.cancelled" and last.data["actor"] == "值班员", last.data diff --git a/backend/tests/test_team_exhausted.py b/backend/tests/test_team_exhausted.py new file mode 100644 index 00000000..9fc5137d --- /dev/null +++ b/backend/tests/test_team_exhausted.py @@ -0,0 +1,214 @@ +"""协作团队用完轮数、调度者始终没判定完成:不许把成员原话当作成功结果交出去。 + +一次真实运行:成员没有工具,模型把工具调用标记当文字写了四轮;调度者每轮都说 +「核对员还没查到数据,本轮继续派核对员」,四轮用完,节点把核对员最后那段标记 +当作结论交了出去,运行 succeeded。负责写结论的成员一次都没被派到。 + +新配置 on_exhausted:fail(默认)判失败,说清调度者最后一轮的理由、谁从没被派到; +degrade 照样交付,但产出里带 exhausted: true、发 team_exhausted 警告,复核和出具 +都据此降档。 +""" +from __future__ import annotations + +import asyncio +import json + +import pytest +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine import review +from app.engine.runner import run_manager + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def _never_done(monkeypatch) -> None: + """调度者每轮都继续派核对员,理由一样;核对员每次都交不出东西。""" + from app.providers import mock_model + + def _decide(self, messages): + if self.response_format: + return AIMessage(content=json.dumps({ + "assignments": [{"agent": "核对员", "instruction": "独立重查一遍"}], + "done": False, "reason": "核对员还没拿到真实条数,定稿要等复核结果", + }, ensure_ascii=False)) + return AIMessage(content="我还需要再查一次。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + + +def _graph(**team): + return { + "nodes": [ + node("start", "input", fields=[{"name": "question"}]), + node("team", "supervisor", goal="核对订单条数", agents=[ + {"name": "核对员", "system": "你负责复核", "max_steps": 1}, + {"name": "撰稿员", "system": "你负责写结论", "max_steps": 1}, + ], **{"max_rounds": 3, **team}), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.team.text }}"}], + contract={"metrics_from": "card", "narrative": "{{ nodes.team.text }}"}), + node("card", "metrics", metrics=[{"id": "n", "expression": "1"}]), + ], + "edges": [{"source": "start", "target": "card"}, {"source": "card", "target": "team"}, + {"source": "team", "target": "out"}], + } + + +async def _finish(graph) -> tuple[Run, list[RunEvent]]: + run = await run_manager.start(graph=graph, input_payload={"question": "x"}) + for _ in range(300): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed"): + break + async with SessionLocal() as session: + events = list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq) + )).scalars()) + return row, events + + +async def test_running_out_of_rounds_fails_by_default(monkeypatch): + _never_done(monkeypatch) + row, events = await _finish(_graph()) + assert row.status == "failed", f"成员原话被当成了结论:{row.output}" + assert "协作团队用完 3 轮仍未完成:核对员还没拿到真实条数,定稿要等复核结果" in row.error + assert "撰稿员" in row.error, "从没被派过的成员要点出来" + assert row.error_node_id == "team" + + +async def test_degrade_delivers_but_says_so(monkeypatch): + _never_done(monkeypatch) + row, events = await _finish(_graph(on_exhausted="degrade")) + assert row.status == "succeeded", row.error + + finished = next(e.data for e in events if e.type == "node.finished" and e.node_id == "team") + assert finished["preview"]["exhausted"] is True + warn = next(e.data for e in events if e.type == "log" and e.data.get("code") == "team_exhausted") + assert "撰稿员" in warn["message"] and "3 轮" in warn["message"] + + # 出具降档,理由写在 gaps 里 + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded" + assert any("用完 3 轮" in g for g in issuance["gaps"]), issuance["gaps"] + # 复核也认这条警告 + signals = review.scan(events, row.output) + assert any(s.kind == "team_exhausted" for s in signals), signals + + +async def test_a_team_that_finishes_is_unaffected(monkeypatch): + from app.providers import mock_model + + turns = {"n": 0} + + def _decide(self, messages): + if self.response_format: + turns["n"] += 1 + plan = ({"assignments": [{"agent": "核对员", "instruction": "查"}]} if turns["n"] == 1 + else {"assignments": [], "done": True, "reason": "查清楚了"}) + return AIMessage(content=json.dumps(plan, ensure_ascii=False)) + return AIMessage(content="一共 1 条。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + row, events = await _finish(_graph()) + assert row.status == "succeeded", row.error + assert not [e for e in events if e.data.get("code") == "team_exhausted"] + + +# -------------------------------------------------------------------------- +# 最后一轮派出去的成员交回来之后,调度者还要看一眼 +# -------------------------------------------------------------------------- + + +async def test_a_pipeline_that_needs_exactly_max_rounds_succeeds(monkeypatch): + """2 轮的流水线(查数 → 撰稿),最多轮数也是 2:第二轮的撰稿员交回来之后,调度者 + 以前再没机会说「完成」,轮数一到就算用完,缺省判失败。""" + from app.providers import mock_model + + routed = {"n": 0} + + def _decide(self, messages): + if self.response_format: + text = "\n".join(str(m.content) for m in messages) + if "轮数已经用完" in text: + plan = {"assignments": [], "done": "撰稿员" in text and "结论" in text, + "reason": "结论已经写好"} + else: + routed["n"] += 1 + who = "核对员" if routed["n"] == 1 else "撰稿员" + plan = {"assignments": [{"agent": who, "instruction": "做你那部分"}], + "done": False, "reason": f"交给{who}"} + return AIMessage(content=json.dumps(plan, ensure_ascii=False)) + return AIMessage(content="结论:一共 1 条。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + row, events = await _finish(_graph(max_rounds=2)) + assert row.status == "succeeded", row.error + assert row.output["r"] == "结论:一共 1 条。" + closing = [e.data for e in events if e.type == "agent.route.end" and e.data.get("closing")] + assert len(closing) == 1 and closing[0]["done"] is True, closing + assert not [e for e in events if e.data.get("code") == "team_exhausted"] + + +async def test_the_closing_look_can_still_say_not_done(monkeypatch): + _never_done(monkeypatch) + row, events = await _finish(_graph(max_rounds=2)) + assert row.status == "failed" + closing = [e.data for e in events if e.type == "agent.route.end" and e.data.get("closing")] + assert len(closing) == 1 and closing[0]["done"] is False, closing + # 收尾那一次也是调度者的一次模型调用,要记账 + coordinator = [e for e in events if e.type == "llm.end" and e.data.get("agent") == "调度者"] + assert len(coordinator) == 3 + + +# -------------------------------------------------------------------------- +# 接着跑一个失败的团队节点:前几轮取自断点,时间线上不再出现一遍 +# -------------------------------------------------------------------------- + + +async def test_continuing_a_failed_team_does_not_replay_its_rounds(monkeypatch): + _never_done(monkeypatch) + row, events = await _finish(_graph(max_rounds=2)) + assert row.status == "failed" + + def tally(evs): + kinds = ("agent.route.start", "agent.route.end", "agent.step.start", "agent.step.end", + "llm.end") + counts = {k: sum(1 for e in evs if e.type == k and e.node_id == "team") for k in kinds} + counts["调度"] = sum(1 for e in evs if e.type == "log" and e.node_id == "team" + and str(e.data.get("message", "")).startswith("调度 →")) + return counts + + before = tally(events) + assert before["agent.step.start"] == 2 and before["agent.route.start"] == 3, before + + graph = _graph(max_rounds=2, on_exhausted="degrade") + await run_manager.continue_failed(row.id, graph=graph) + for _ in range(300): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, row.id) + if row.status in ("succeeded", "failed"): + break + assert row.status == "succeeded", row.error + async with SessionLocal() as session: + after_events = list((await session.execute( + select(RunEvent).where(RunEvent.run_id == row.id).order_by(RunEvent.seq) + )).scalars()) + assert tally(after_events) == before, "接着跑把前几轮的调度和成员又发了一遍" + # 前几轮取自断点,用量不能跟着丢:成员的调用也要算进去 + assert row.usage["calls"] == before["llm.end"], (row.usage, before) diff --git a/backend/tests/test_tool_deadline.py b/backend/tests/test_tool_deadline.py new file mode 100644 index 00000000..cebdc71a --- /dev/null +++ b/backend/tests/test_tool_deadline.py @@ -0,0 +1,265 @@ +"""工具的时限要按时生效,并且事先告诉界面这个时限是多少。 + +真实运行里有一次查询:tool.end 的 duration_ms 是 90628,报错却写着「查询超过 30s +被中断」。30 秒时限确实到了,取消也发出去了——但 asyncio.wait_for 取消之后要**等被 +取消的那一方收完尾**,而驱动收尾(关连接、回滚)要等数据库把那条语句跑完。于是 +「30 秒被中断」实际等了 90 秒,报错还说的是 30。 + +这里把两件事钉住: +- 到了时限,节点就拿回控制权、如实说「等了多久、放弃了」,驱动的收尾放到后台; +- tool.start 带上 timeout_s,界面据此写「已超出 30s 上限」,不在前端写死。 + +慢收尾的工具用两种方式造:一个假的(取消后还要磨蹭一阵才肯退出),和一个真的 +SQLite 查询(aiosqlite 在线程里跑语句,关连接要排在那条语句后面)。 +""" +from __future__ import annotations + +import asyncio +import dataclasses +import json +import sqlite3 +import uuid + +import pytest +from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.tools import StructuredTool +from pydantic import BaseModel +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, RunEvent +from app.engine.runner import run_manager + +#: 假工具声明的时限。取消之后它还要再磨蹭 CLEANUP 秒才退出,像一个在等服务端的驱动 +LIMIT = 0.3 +CLEANUP = 2.0 + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + # 被放弃的调用在后台收尾(假工具要再磨蹭 CLEANUP 秒,SQLite 要等语句跑完才还连接)。 + # 等它们收完再结束,测试的事件循环关掉时不该还挂着占着连接的任务 + others = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + if others: + await asyncio.wait(others, timeout=15) + + +class _Args(BaseModel): + sql: str + + +def _slow_tool(name: str = "slow_lookup") -> StructuredTool: + async def _run(sql: str) -> str: + try: + await asyncio.sleep(30) + except asyncio.CancelledError: + await asyncio.sleep(CLEANUP) # 驱动收尾:等服务端把语句跑完才肯关连接 + raise + return "不会走到这里" + + return StructuredTool(name=name, description="慢查询", args_schema=_Args, coroutine=_run, + func=None, metadata={"timeout_s": LIMIT}) + + +def _serve(monkeypatch, tool: StructuredTool) -> None: + """让各节点按名字拿到这个工具。""" + async def _build(names, ctx, *, session=None): + return [tool] if tool.name in names else [] + + import app.engine.nodes.llm as llm_nodes + import app.engine.nodes.multi as multi_nodes + import app.engine.nodes.tools as tool_nodes + import app.tools.registry as registry + + for module in (llm_nodes, multi_nodes, tool_nodes, registry): + monkeypatch.setattr(module, "build_tools", _build) + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), + "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +async def _finish(graph) -> tuple[Run, list[RunEvent]]: + run = await run_manager.start(graph=graph, input_payload={"question": "x"}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed", "cancelled", "interrupted"): + break + else: + pytest.fail("运行没有在 20 秒内结束") + async with SessionLocal() as session: + events = list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq) + )).scalars()) + return row, events + + +def _script_one_call(monkeypatch, tool_name: str, sql: str = "SELECT 1") -> list[str]: + """第一轮调一次工具,看到结果就收口。返回模型看到的工具结果,供断言。""" + from app.providers import mock_model + + seen: list[str] = [] + + def _decide(self, messages): + results = [m for m in messages if isinstance(m, ToolMessage)] + if self.response_format: + return AIMessage(content=json.dumps( + {"assignments": [], "done": True} if seen or results else + {"assignments": [{"agent": "查数员", "instruction": "查一下"}]})) + if self.tools and not results: + return AIMessage(content="", tool_calls=[ + {"name": tool_name, "args": {"sql": sql}, "id": "call_slow"}]) + seen.extend(str(m.content) for m in results) + return AIMessage(content="查询超时了,缩小范围再试。") + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + return seen + + +# -------------------------------------------------------------------------- + + +async def test_an_agent_gets_control_back_when_the_limit_is_reached(monkeypatch): + tool = _slow_tool() + _serve(monkeypatch, tool) + seen = _script_one_call(monkeypatch, tool.name) + row, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("bot", "agent", prompt="查", tools=[tool.name], approval="never", max_steps=3), + node("out", "output", fields=[]), + )) + assert row.status == "succeeded", row.error + + start = next(e.data for e in events if e.type == "tool.start") + assert start["timeout_s"] == LIMIT, start + end = next(e.data for e in events if e.type in ("tool.end", "tool.error")) + assert end["duration_ms"] < (LIMIT + CLEANUP) * 1000 * 0.75, \ + f"时限 {LIMIT}s,却等到了驱动收尾:{end['duration_ms']}ms" + assert end.get("timed_out") is True, end + # 模型要知道是超时、超了多久,才会去缩小查询范围 + assert seen and "0.3" in seen[0] and "放弃" in seen[0], seen + + +async def test_a_tool_node_reports_the_real_limit(monkeypatch): + tool = _slow_tool() + _serve(monkeypatch, tool) + row, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("q", "tool", tool=tool.name, args={"sql": "SELECT 1"}, approval="never"), + node("out", "output", fields=[]), + )) + assert row.status == "failed" + assert "0.3" in (row.error or "") and "放弃" in (row.error or ""), row.error + start = next(e.data for e in events if e.type == "tool.start") + assert start["timeout_s"] == LIMIT + err = next(e.data for e in events if e.type == "tool.error") + assert err["duration_ms"] < (LIMIT + CLEANUP) * 1000 * 0.75, err + assert err.get("timed_out") is True + + +async def test_a_team_member_gets_control_back_too(monkeypatch): + tool = _slow_tool() + _serve(monkeypatch, tool) + _script_one_call(monkeypatch, tool.name) + row, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("team", "supervisor", goal="查", max_rounds=3, approval="never", agents=[ + {"name": "查数员", "system": "你负责查库", "tools": [tool.name], "max_steps": 3}]), + node("out", "output", fields=[]), + )) + assert row.status == "succeeded", row.error + start = next(e.data for e in events if e.type == "tool.start") + assert start["timeout_s"] == LIMIT + end = next(e.data for e in events if e.type in ("tool.end", "tool.error")) + assert end["duration_ms"] < (LIMIT + CLEANUP) * 1000 * 0.75, end + + +# -------------------------------------------------------------------------- +# 真实的驱动:SQLite 的慢查询。关连接要排在那条语句后面 +# -------------------------------------------------------------------------- + +#: 一条在时限之前一定跑不完的查询:pause 是测试里注册给 SQLite 的函数,在执行语句的 +#: 那个线程里睡够秒数再返回。以前用递归 CTE 造慢查询,快几倍的机器上它不到 1 秒就跑完了 +PAUSE_S = 4 +SLOW_SQL = f"SELECT pause({PAUSE_S}) AS n" + + +@pytest.fixture +async def slow_source(tmp_path, monkeypatch): + import time + + from sqlalchemy import event + + from app.data import guard + from app.data.engine import engines + import app.tools.datasource as datasource_tools + + @dataclasses.dataclass(frozen=True) + class OneSecond(guard.QueryLimits): + timeout_seconds: int = 1 + + monkeypatch.setattr(guard, "QueryLimits", OneSecond) + monkeypatch.setattr(datasource_tools, "QueryLimits", OneSecond) + + path = tmp_path / "orders.db" + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE orders (id INTEGER)") + conn.commit() + conn.close() + name = f"shop_{uuid.uuid4().hex[:6]}" + async with SessionLocal() as session: + source = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, + description="测试库", options={}, schema_cache={}, enabled=True) + session.add(source) + await session.commit() + + def _register(dbapi_conn, _record): + dbapi_conn.create_function("pause", 1, lambda seconds: time.sleep(seconds) or seconds) + + engine = (await engines.get(source)).sync_engine + event.listen(engine, "connect", _register) + yield f"db_query__{name}" + event.remove(engine, "connect", _register) + + +async def test_a_slow_query_is_abandoned_on_time(slow_source, monkeypatch): + _script_one_call(monkeypatch, slow_source, SLOW_SQL) + row, events = await _finish(chain( + node("start", "input", fields=[{"name": "question"}]), + node("bot", "agent", prompt="查", tools=[slow_source], max_steps=3), + node("out", "output", fields=[]), + )) + assert row.status == "succeeded", row.error + start = next(e.data for e in events if e.type == "tool.start") + assert start["timeout_s"] == 1, start + end = next(e.data for e in events if e.type in ("tool.end", "tool.error")) + assert end["duration_ms"] < 3000, f"1s 的查询时限等了 {end['duration_ms']}ms" + assert "1s" in end["preview"], end + + +def test_where_each_limit_comes_from(): + from app.data.guard import QueryLimits + from app.engine.toolcalls import limit_fields, limit_of + + query = limit_of(None, "db_query__shop", {"sql": "SELECT 1"}) + assert query.seconds == QueryLimits().timeout_seconds and query.enforce and query.self_timed + # 数据源自己声明了时限:用它,但仍按「数据层自己掐、自己收尾」对待 + declared_query = limit_of(_slow_tool("db_query__shop"), "db_query__shop", {"sql": "x"}) + assert declared_query.seconds == LIMIT and declared_query.self_timed + # 沙箱的时限由沙箱执行:界面要知道上限,引擎不按它掐(里面含着起虚拟机的时间) + sandbox = limit_of(None, "python_exec", {"code": "print(1)", "timeout": 45}) + assert sandbox.seconds == 45 and not sandbox.enforce + assert limit_of(None, "calculator", {"expression": "1+1"}) is None + assert limit_fields(query) == {"timeout_s": QueryLimits().timeout_seconds} + assert limit_fields(None) == {} diff --git a/backend/tests/test_validate_repair.py b/backend/tests/test_validate_repair.py new file mode 100644 index 00000000..53ffcc7e --- /dev/null +++ b/backend/tests/test_validate_repair.py @@ -0,0 +1,170 @@ +"""校验节点的自动修复只许改格式,不许补数据。 + +一次真实运行:上游的查询 agent 没有工具,交上来的是「假设调用工具」加一段 SQL, +里面一个数都没有。校验第一次失败(缺 total_count),自动修复把原文喂给模型让它 +「修正后重新输出」——模型就编了一个 total_count: 0,校验通过,运行成功,最后 +交到用户手上的是一个凭空的 0。 + +修复提示词要说清楚「原文没有的值填 null」,但光靠提示词拦不住。修复之后要核对: +修复结果里出现了原文没有的数字或文字,这次修复就作废,报错写清是哪个值。 +另外修复是一次真实的模型调用,它的用量以前没发 llm.end,也没算进 usage。 +""" +from __future__ import annotations + +import asyncio +import json + +import pytest +from langchain_core.messages import AIMessage +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine.runner import run_manager + +SCHEMA = { + "type": "object", + "properties": {"total_count": {"type": "integer"}, "table": {"type": "string"}}, + "required": ["total_count"], +} + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, + "data": {"label": nid, "config": config}} + + +def _graph(upstream_text: str, **validate): + return { + "nodes": [ + node("start", "input"), + node("draft", "transform", mode="template", template=upstream_text), + node("check", "validate", schema=SCHEMA, source="{{ nodes.draft }}", max_retries=2, + **validate), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.check.data }}"}]), + ], + "edges": [{"source": "start", "target": "draft"}, {"source": "draft", "target": "check"}, + {"source": "check", "target": "out"}], + } + + +def _repairs_with(monkeypatch, *replies: dict) -> list[str]: + """修复模型依次交回这些 JSON。返回它收到的提示词。""" + from app.providers import mock_model + + prompts: list[str] = [] + + def _decide(self, messages): + prompts.append("\n".join(str(m.content) for m in messages)) + reply = replies[min(len(prompts) - 1, len(replies) - 1)] + return AIMessage(content=json.dumps(reply, ensure_ascii=False)) + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + return prompts + + +async def _finish(graph) -> tuple[Run, list[RunEvent]]: + run = await run_manager.start(graph=graph, input_payload={}) + for _ in range(200): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed"): + break + async with SessionLocal() as session: + events = list((await session.execute( + select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq) + )).scalars()) + return row, events + + +PRETEND = "我假设调用工具 db_query__shop 执行:\nSELECT COUNT(*) FROM orders\n结果应该能反映订单总数。" + + +async def test_a_repair_that_invents_a_number_fails(monkeypatch): + prompts = _repairs_with(monkeypatch, {"total_count": 0}) + row, events = await _finish(_graph(PRETEND)) + + assert row.status == "failed", f"修复编出来的 0 被当成了结果:{row.output}" + assert "修复时出现了原文没有的值:total_count=0" in (row.error or ""), row.error + # 提示词里要把规矩说在前面 + assert prompts and "null" in prompts[0] and "编造" in prompts[0] + + +async def test_a_repair_that_only_reformats_passes(monkeypatch): + _repairs_with(monkeypatch, {"total_count": 1284, "table": "orders"}) + row, _ = await _finish(_graph("查到了:orders 表一共 1,284 条记录。")) + assert row.status == "succeeded", row.error + assert json.loads(row.output["r"]) == {"total_count": 1284, "table": "orders"} + + +async def test_a_repair_that_admits_there_is_no_data_still_fails_the_schema(monkeypatch): + _repairs_with(monkeypatch, {"total_count": None}) + row, _ = await _finish(_graph(PRETEND)) + assert row.status == "failed" + assert "total_count" in (row.error or "") and "原文没有的值" not in (row.error or "") + + +async def test_repair_calls_are_counted(monkeypatch): + _repairs_with(monkeypatch, {"total_count": 0}) + row, events = await _finish(_graph(PRETEND)) + ends = [e.data for e in events if e.type == "llm.end" and e.node_id == "check"] + assert len(ends) == 2, "两次修复各是一次模型调用,都要记账" + assert row.usage.get("output_tokens", 0) >= sum(e["output_tokens"] for e in ends) > 0 + + +async def test_numbers_written_in_chinese_count_as_present(monkeypatch): + _repairs_with(monkeypatch, {"total_count": 12, "table": "orders"}) + row, _ = await _finish(_graph("orders 表里一共有十二条记录。")) + assert row.status == "succeeded", row.error + + +# SQL 里的数字不是证据:WHERE status > 0、LIMIT 10 这类写法到处都是,认它们的话, +# 编出来的 total_count: 0 就有了「出处」——这恰恰是「假设调用工具 + SQL」最常见的形状 + + +@pytest.mark.parametrize("upstream, invented", [ + ('{"sql":"SELECT COUNT(*) FROM orders WHERE status > 0"}', {"total_count": 0}), + ('假设调用工具:{"tool": "db_query__shop", "sql": "SELECT COUNT(*) FROM orders LIMIT 10"}', + {"total_count": 10}), + ("我假设调用工具执行了 SELECT COUNT(*) FROM orders WHERE deleted = 0,结果还没拿到", + {"total_count": 0}), + ("```sql\nSELECT COUNT(*) FROM orders\nWHERE status > 0\nLIMIT 10\n```\n查询已提交", {"total_count": 10}), + ('{"sql":"SELECT COUNT(*) FROM orders WHERE status > 0"}', {"total_count": "0"}), +]) +def test_numbers_inside_sql_do_not_count(upstream, invented): + from app.engine.nodes.human import _invented + + assert _invented(invented, upstream, SCHEMA), (upstream, invented) + + +def test_numbers_outside_the_sql_still_count(): + from app.engine.nodes.human import _invented + + text = "执行了 SELECT COUNT(*) FROM orders WHERE status > 0,结果一共 12 条" + assert _invented({"total_count": 12}, text, SCHEMA) == [] + fenced = '```json\n{"total_count": 12}\n```' + assert _invented({"total_count": 12}, fenced, SCHEMA) == [] + + +def test_a_percentage_may_be_written_as_a_fraction(): + from app.engine.nodes.human import _invented + + assert _invented({"rate": 0.125, "mom": -0.03}, "转化率 12.5%,环比 -3%", {}) == [] + assert _invented({"rate": 12.5}, "转化率 12.5%", {}) == [] + assert _invented({"rate": 0.2}, "转化率 12.5%", {}) == ["rate=0.2"] + + +async def test_a_repair_that_takes_its_number_from_the_sql_fails(monkeypatch): + _repairs_with(monkeypatch, {"total_count": 0}) + row, _ = await _finish(_graph('假设调用工具:{"tool": "db_query__shop", ' + '"sql": "SELECT COUNT(*) FROM orders WHERE status > 0"}')) + assert row.status == "failed", f"SQL 里的 0 被当成了出处:{row.output}" + assert "修复时出现了原文没有的值:total_count=0" in (row.error or ""), row.error diff --git a/backend/tests/test_validation_text.py b/backend/tests/test_validation_text.py index 69cacfec..14b2dc72 100644 --- a/backend/tests/test_validation_text.py +++ b/backend/tests/test_validation_text.py @@ -173,7 +173,7 @@ async def test_resume_refusal_does_not_leak_the_status_enum(engine_up): def test_exception_wording_helper(): - from app.engine.errors import describe_exception + from app.core.errors import describe_exception assert "query" in describe_exception(TypeError(RAW)) assert "TypeError" not in describe_exception(TypeError(RAW)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18cbba7c..91be0f5e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import { Link, NavLink, Navigate, Route, Routes, useLocation, useNavigate } from import { Bell, BellOff, Moon, RotateCw, Sun, UserRound } from 'lucide-react' import clsx from 'clsx' import { ApiError, NETWORK_MESSAGE, api } from './api/client' -import { useCatalog, useOnReconnect } from './store/catalog' +import { CATALOG_TIMEOUT_MS, catalogListState, useCatalog, useOnReconnect } from './store/catalog' import type { CatalogCheck } from './store/catalog' import { useStudio } from './store/studio' import { useChat } from './store/chat' @@ -22,7 +22,7 @@ import { CommandPalette, PAGES, ShortcutHelp, flushThemePref, openShortcutHelp, toggleCommandPalette, toggleNotify, toggleTheme, useTheme, waitedFor, } from './components/CommandPalette' -import { ErrorBoundary, Kbd, OfflineBanner, Spinner, StatusBadge, isComposing, toast } from './components/ui' +import { ErrorBoundary, Kbd, OfflineBanner, Spinner, StatusBadge, isComposing, toast, useTicker } from './components/ui' import { applyTheme, normalizeTheme } from './lib/theme' import { ariaShortcut, isTypingTarget, matchShortcut } from './lib/keys' import { formatDateTime, formatDuration, formatRelative, shortId } from './lib/format' @@ -32,6 +32,7 @@ import { composeTitle, flagAttention, notifyInBackground, notifySupported, paintFavicon, useSignals, } from './lib/notify' import type { Attention, FaviconDot } from './lib/notify' +import { useLeaveBlocker } from './lib/leave' export default function App() { const loaded = useCatalog((s) => s.loaded) @@ -66,7 +67,6 @@ export default function App() { // 断线时换的那一下当时没存上:恢复后补存,而不是去读服务端 useOnReconnect(() => { if (!flushThemePref()) loadTheme() }) - useBootReport(loaded) // 在 App 这一层算,从启动那一刻起计时:从启动页「先进去看看」时已经卡了好几秒, // 进门那一下就得是琥珀,不能进了门再从头等 const stalled = useStalledChecks() @@ -74,8 +74,8 @@ export default function App() { // 从没连上过就断着:停在启动页说清楚原因,而不是放人进去看一屏「还没有…」 const offlineAtBoot = !everOk && (backend === 'down' || (allFailed && backend === 'checking')) const booting = !offlineOk && (!loaded || offlineAtBoot) - // 启动页的清单自己会说哪几项还在等;进了外壳才需要当面提醒 - useStallWarning(booting ? NO_CHECKS : stalled) + // 启动页的清单自己会说哪几项还在等、哪几项失败了;进了外壳才需要当面提醒 + useCatalogWarning(stalled, !booting) if (booting) return setOfflineOk(true)} /> return ( @@ -123,6 +123,7 @@ export default function App() { + ) } @@ -131,6 +132,15 @@ export default function App() { // 全局快捷键 // ------------------------------------------------------------------------- +/** + * 离开前确认的那一个 blocker(lib/leave):导航、链接、⌘K、⌥ 数字、浏览器后退, + * 换页都先经过页面登记的守卫。单独成一个组件,blocker 变了只重绘它 + */ +function LeaveBlocker() { + useLeaveBlocker() + return null +} + /** 启动页上不挂:那时按下的 ⌘K 会在加载完之后才突然弹出面板 */ function ShellKeys() { const navigate = useNavigate() @@ -161,6 +171,7 @@ function ShellKeys() { const page = PAGES.find((p) => matchShortcut(e, p.shortcut)) if (page) { e.preventDefault() + // 有没保存的改动时由 LeaveBlocker 先问,这里照常跳 navRef.current(page.to) } } @@ -454,10 +465,10 @@ const STALL_MS = 3000 /** * 卡着没回来的 catalog 请求(后端没断时)。 * - * refresh 是一个 Promise.all:一张表卡住(后端连得上、就是不回),六张表全都还是 - * 空的,backend 却是 ok——遥测点一片绿,编排页一屏「还没有工作流 / 新建工作流」, - * 正是离线横幅要消灭的那种假空态,只是换了个来路。正常刷新几十毫秒就回,不到 - * STALL_MS 不算,免得每次刷新遥测点都闪一下琥珀。 + * refresh 按表各自请求、先回先填,每个请求最多等 CATALOG_TIMEOUT_MS。一张表卡住 + * (后端连得上、就是不回)只拖它自己:卡着的这段时间遥测点是「加载中」,到点那一项 + * 记成出错,遥测点转成「降级」。正常刷新几十毫秒就回,不到 STALL_MS 不算,免得 + * 每次刷新遥测点都闪一下琥珀。 */ function useStalledChecks(): CatalogCheck[] { const waiting = useCatalog((s) => s.checks.some((c) => c.state === 'pending')) @@ -473,26 +484,52 @@ function useStalledChecks(): CatalogCheck[] { return stalled && waiting && !down ? checks.filter((c) => c.state === 'pending') : NO_CHECKS } +/** 失败的那一项怎么说:回了错误码写码;等满 CATALOG_TIMEOUT_MS 才断的是超时;其余是够不着 */ +function failWord(c: CatalogCheck): string { + if (c.status) return String(c.status) + return c.ms != null && c.ms >= CATALOG_TIMEOUT_MS ? '超时' : '连不上' +} + /** - * 进了外壳还有请求卡着:挂一条常驻提示,点名是哪几项;全部回来(或者断开,归 - * 横幅管)就撤掉。遥测点只是一个小琥珀点,人眼前是「还没有工作流」和一个「新建」 - * 按钮,得当面说一句。点名的是卡住那一刻的几项,实时的看遥测浮层。 + * 眼下是空的、却不能当「没有」的那几张表:从没取回来过(catalogListState 不是 ok), + * 请求又卡着(超过 STALL_MS)或者已经失败了(回了错误码、超时)。人眼前是「还没有 + * 工作流」和一个「新建」按钮,得当面说一句,而且一直挂着,直到那几张表取回来: + * 卡着的那一项到点超时,提示跟着改口说「没加载成功」,不能悄悄撤掉。 + * + * 取回来过的表刷新卡住、失败了都不算:失败不清空,手上的还是真数据,遥测点说一声 + * 就够。后端断开归离线横幅说;启动页上清单自己会说(enabled=false)。 */ -function useStallWarning(stalled: CatalogCheck[]) { - const any = stalled.length > 0 - const latest = useRef(stalled) - latest.current = stalled +function useCatalogWarning(stalled: CatalogCheck[], enabled: boolean) { + // 选成字符串:只在「是哪几张表」变了的时候才换提示 + const waiting = useCatalog((s) => stalled + .filter((c) => catalogListState(s, c.key) === 'loading').map((c) => c.key).join(',')) + const failed = useCatalog((s) => s.checks + .filter((c) => catalogListState(s, c.key) === 'error').map((c) => c.key).join(',')) useEffect(() => { - if (!any) return - const labels = latest.current.map((c) => c.label) - const id = toast.warn(`还有 ${labels.length} 项没取回来(${labels.join('、')}):列表可能不全,先别新建`, { + if (!enabled || (!waiting && !failed)) return + const { checks } = useCatalog.getState() + const pick = (keys: string) => keys.split(',').filter(Boolean) + .map((k) => checks.find((c) => c.key === k)).filter((c): c is CatalogCheck => !!c) + const w = pick(waiting) + const f = pick(failed) + const text = [ + w.length ? `还有 ${w.length} 项没取回来(${w.map((c) => c.label).join('、')})` : '', + f.length ? `${w.length ? '' : '有 '}${f.length} 项没加载成功(${f.map((c) => `${c.label} ${failWord(c)}`).join('、')})` : '', + ].filter(Boolean).join(',') + const detail = [ + w.length ? '后端连得上,但这几个请求发出去好几秒了还没回来。' : '', + ...f.map((c) => `${c.label}:${c.error ?? failWord(c)}`), + '在它们取回来之前,对应的列表和下拉可能是空的,不代表数据没了。导航最底下的连接指示灯里能看到每一项的情况。', + ].filter(Boolean).join('\n') + // 键跟着「是哪几张表」走:同一个键的提示只会累计次数,不会换字 + const id = toast.warn(`${text}:列表可能不全,先别新建`, { sticky: true, - key: 'boot-pending', - detail: '后端连得上,但这几个请求发出去好几秒了还没回来。在它们回来之前,工作流、知识库这些列表可能是空的,不代表数据没了。导航最底下的连接指示灯里能看到哪一项回来了。', + key: `catalog-unknown:${waiting}|${failed}`, + detail, action: { label: '重试', onClick: () => void useCatalog.getState().refresh() }, }) return () => toast.dismiss(id) - }, [any]) + }, [enabled, waiting, failed]) } /** 连接遥测点:导航最底下,像仪表盘上的指示灯。点开看地址、延迟、最近一次错误 */ @@ -535,17 +572,6 @@ function Telemetry({ stalled }: { stalled: CatalogCheck[] }) { ) } -function useTicker(active: boolean, ms = 1000): number { - const [now, setNow] = useState(() => Date.now()) - useEffect(() => { - if (!active) return - setNow(Date.now()) - const t = setInterval(() => setNow(Date.now()), ms) - return () => clearInterval(t) - }, [active, ms]) - return now -} - function TelemetryPanel({ onClose, anchor, stalled }: { onClose: (refocus: boolean) => void anchor: RefObject @@ -560,8 +586,10 @@ function TelemetryPanel({ onClose, anchor, stalled }: { const [busy, setBusy] = useState(false) const ref = useRef(null) const down = backend === 'down' - const now = useTicker(down) + const now = useTicker(1000, down) const tone = backendTone(backend, latency, useFailingChecks(), stalled.length) + // 卡着的里头有没有从没取回来过的:有的话那张表眼下是空的,才要劝一句别急着新建 + const unknown = useCatalog((s) => stalled.some((c) => catalogListState(s, c.key) !== 'ok')) const onCloseRef = useRef(onClose) onCloseRef.current = onClose @@ -634,7 +662,11 @@ function TelemetryPanel({ onClose, anchor, stalled }: {
{stalled.map((c) => c.label).join('、')}还没回来
-
在那之前各页的列表可能不全,不代表数据没了,先别急着新建。
+
+ {unknown + ? '在那之前各页的列表可能不全,不代表数据没了,先别急着新建。' + : '页面上是上次取回来的列表,回来之后自动换新。'} +
)} {checks.length > 0 && ( @@ -667,7 +699,7 @@ function CheckList({ checks, lead }: { checks: CatalogCheck[]; lead?: ReactNode {c.state === 'pending' ? '还在等' : c.state === 'ok' ? '已取回 ' : '失败 '} {c.state === 'pending' ? … - : c.state === 'ok' ? `${c.ms ?? '—'} ms` : c.status ? String(c.status) : '连不上'} + : c.state === 'ok' ? `${c.ms ?? '—'} ms` : failWord(c)} ))} @@ -711,7 +743,7 @@ function BootScreen({ onEnterOffline }: { onEnterOffline: () => void }) { const retryAt = useCatalog((s) => s.retryAt) const loaded = useCatalog((s) => s.loaded) const [t0] = useState(() => Date.now()) - const now = useTicker(true, 250) + const now = useTicker(250) const [busy, setBusy] = useState(false) const elapsed = now - t0 const down = backend === 'down' @@ -719,8 +751,8 @@ function BootScreen({ onEnterOffline }: { onEnterOffline: () => void }) { // 一闪而过的启动(本机几十毫秒)不画清单,免得整屏跳一下 const reveal = down || elapsed > 300 - // 请求没有超时,后端卡死(连得上但不回)时会一直挂着:到点主动探一次 /health, - // 它带超时,卡死和断开都能在这里判出来 + // 每张表要等满 CATALOG_TIMEOUT_MS 才记成失败,后端卡死(连得上但不回)时启动页得 + // 干等 15 秒:到点先主动探一次 /health,它的超时短得多,卡死和断开都能在这里先判出来 useEffect(() => { if (slow) void useCatalog.getState().checkBackend() }, [slow]) const retry = async () => { @@ -827,30 +859,6 @@ function BootScreen({ onEnterOffline }: { onEnterOffline: () => void }) { ) } -/** - * 启动时部分请求失败(后端连着,但某几张表报错):进页面,但要说出来。 - * 不说的话,那几张表对应的下拉、列表就是空的,看着像"没有"。 - */ -function useBootReport(loaded: boolean) { - const reported = useRef(false) - useEffect(() => { - if (!loaded || reported.current) return - reported.current = true - // 只报后端回了错误码的那几项:够不着后端(没有状态码)归启动页和离线横幅说, - // 这里再弹一条「6 项没加载成功」就是同一件事说两遍 - const bad = useCatalog.getState().checks.filter((c) => c.state === 'error' && c.status) - if (!bad.length) return - toast.warn( - `有 ${bad.length} 项没加载成功:${bad.map((c) => (c.status ? `${c.label}(${c.status})` : c.label)).join('、')}`, - { - detail: bad.map((c) => `${c.label}:${c.error ?? ''}`).join('\n'), - action: { label: '重试', onClick: () => void useCatalog.getState().refresh() }, - key: 'boot-partial', - }, - ) - }, [loaded]) -} - // ------------------------------------------------------------------------- // 后台信号:标题、favicon、系统通知 // ------------------------------------------------------------------------- diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cc1de487..05f5b776 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,6 @@ import type { Approval, Conversation, ConversationDetail, ConversationTurn, GraphSpec, - KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, RunStatus, Skill, ToolInfo, + KbDocument, MemoryItem, Provider, ReviewResult, Run, RunEvent, RunStatus, Skill, ToolChange, ToolInfo, ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion, } from '../types' @@ -129,35 +129,107 @@ async function request(path: string, init?: RequestInit & { timeoutMs?: numbe } finally { if (timer) clearTimeout(timer) } - if (!res.ok) { - const text = await res.text().catch(() => '') - let body: any - let isJson = false - try { body = JSON.parse(text); isJson = true } catch { /* 响应体不是 JSON */ } - if (isGatewayFailure(res.status, text, isJson)) { - const err = new ApiError(res.status, NETWORK_MESSAGE, { - kind: 'network', raw: `${res.status} ${res.statusText}${text ? `\n${text.slice(0, 500)}` : ''}`, - }) - report(false, err) - throw err - } - report(true) - if (isJson) { - const detail = body?.detail ?? body - throw new ApiError(res.status, describeDetail(detail), { - detail, raw: typeof body?.raw === 'string' ? body.raw : `${res.status} ${text.slice(0, 2000)}`, - }) - } - const message = res.status >= 500 - ? `后端出错了(${res.status}),详情看服务日志` - : `请求没有成功(${res.status} ${res.statusText})` - throw new ApiError(res.status, message, { raw: `${res.status} ${res.statusText}\n${text.slice(0, 2000)}` }) - } + if (!res.ok) throw failure(res.status, res.statusText, await res.text().catch(() => '')) report(true) if (res.status === 204) return undefined as T return res.json() } +/** 非 2xx 的响应 → ApiError,顺带报告连接状态。fetch 和 XHR 两条路共用 */ +function failure(status: number, statusText: string, text: string): ApiError { + let body: any + let isJson = false + try { body = JSON.parse(text); isJson = true } catch { /* 响应体不是 JSON */ } + if (isGatewayFailure(status, text, isJson)) { + const err = new ApiError(status, NETWORK_MESSAGE, { + kind: 'network', raw: `${status} ${statusText}${text ? `\n${text.slice(0, 500)}` : ''}`, + }) + report(false, err) + return err + } + report(true) + if (isJson) { + const detail = body?.detail ?? body + return new ApiError(status, describeDetail(detail), { + detail, raw: typeof body?.raw === 'string' ? body.raw : `${status} ${text.slice(0, 2000)}`, + }) + } + const message = status >= 500 + ? `后端出错了(${status}),详情看服务日志` + : `请求没有成功(${status} ${statusText})` + return new ApiError(status, message, { raw: `${status} ${statusText}\n${text.slice(0, 2000)}` }) +} + +/** 上传进度。字节发完(sent)之后还要等后端解析、切块,那段没有进度可报 */ +export interface UploadProgress { + /** 已经发出去的字节 */ + loaded: number + /** 总字节;浏览器算不出来时为 null,这时只能画已发多少,不画百分比 */ + total: number | null + /** 字节已经全部发完,在等后端处理 */ + sent: boolean +} + +export interface UploadOptions { + onProgress?: (p: UploadProgress) => void + signal?: AbortSignal +} + +/** + * 带字节进度的上传。fetch 拿不到上传进度,只能用 XHR;报错、连接状态、署名头和 + * request 走同一套,调用方看到的 ApiError 没有区别。取消照旧抛 AbortError。 + */ +function upload(path: string, form: FormData, opts?: UploadOptions): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', BASE + path) + for (const [k, v] of Object.entries(actorHeader())) xhr.setRequestHeader(k, v) + const onProgress = opts?.onProgress + if (onProgress) { + // 进度事件里的字节数含 multipart 的边界和表头,比文件本身略大;发完那一下 + // 沿用它的总数,前后两个数才对得上 + let total: number | null = null + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) total = e.total + onProgress({ loaded: e.loaded, total, sent: false }) + } + xhr.upload.onload = () => { + const bytes = total ?? [...form.values()].reduce((n, v) => n + (v instanceof Blob ? v.size : 0), 0) + onProgress({ loaded: bytes, total: bytes || null, sent: true }) + } + } + const abort = () => xhr.abort() + opts?.signal?.addEventListener('abort', abort) + const cleanup = () => opts?.signal?.removeEventListener('abort', abort) + xhr.onload = () => { + cleanup() + if (xhr.status < 200 || xhr.status >= 300) { + reject(failure(xhr.status, xhr.statusText, xhr.responseText ?? '')) + return + } + report(true) + if (xhr.status === 204 || !xhr.responseText) { resolve(undefined as T); return } + try { + resolve(JSON.parse(xhr.responseText)) + } catch { + reject(new ApiError(xhr.status, '后端回的内容读不懂', { raw: xhr.responseText.slice(0, 2000) })) + } + } + xhr.onerror = () => { + cleanup() + const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `XMLHttpRequest error: POST ${path}` }) + report(false, err) + reject(err) + } + xhr.onabort = () => { + cleanup() + reject(new DOMException('上传已取消', 'AbortError')) + } + if (opts?.signal?.aborted) { xhr.abort(); return } + xhr.send(form) + }) +} + /** * 数据源测连接的结果。ok=false 时 error 是一句人话、hint 是怎么办、detail 是 * 驱动的原始报错(放进「技术细节」)。 @@ -197,6 +269,89 @@ export interface ProviderDraft { extra?: Record } +/** 探查结构的预览(dry_run):只看不存,缓存和同步时间都没动 */ +export interface IntrospectPreview { + dry_run: true + /** 这次探的是哪个 schema,'' 是默认 schema */ + schema: string + table_count: number + /** 带 schema 前缀的对象全名,最多 200 个 */ + tables: string[] + truncated: boolean + total: number + schema_error: string + available_schemas: string[] +} + +/** 一张表的结构化列信息(和给模型看的 detail 文本出自同一份缓存) */ +export interface SchemaColumn { + name: string + type: string + pk: boolean + not_null: boolean + comment: string | null +} + +export interface TableSchema { + table: string + /** 给模型看的那段文本 */ + detail: string + found: boolean + qualified: string | null + kind: 'table' | 'view' | null + comment: string | null + /** 老后端没有,只有 detail */ + columns?: SchemaColumn[] +} + +/** 知识库检索的一条命中 */ +export interface KbHit { + chunk_id: string + document_id: string + title: string + ordinal: number + content: string + score: number + signals?: Record + /** 向量、关键词两路各贡献了多少分,两者之和等于 score。老后端不给 */ + contrib?: { vector: number; keyword: number } + [key: string]: any +} + +export interface KbSearchResult { + query: string + collection: string | null + results: KbHit[] + /** 这次检索降级了(比如退回纯关键词)的说明 */ + degraded: string[] + /** 实际用的混合权重 */ + alpha?: number + [key: string]: any +} + +/** + * 后台重建索引的进度。phase:chunks(知识库切块)→ index(倒排,没有分批进度)→ + * memories(长期记忆)→ done。total / done 是切块加记忆的合计 + */ +export interface ReindexJob { + id: string + state: 'running' | 'done' | 'failed' + phase: 'chunks' | 'index' | 'memories' | 'done' + collection: string | null + total: number + done: number + chunks: { total: number; done: number } + memories: { total: number; done: number } + started_at: string + finished_at: string | null + error: string | null + hint: string | null + result: { reindexed: number; memories_reindexed: number; embedder: string; [key: string]: any } | null +} + +/** runs.list 的 workflow_id 传它:只看没保存成工作流的那些运行(问数据页、画布上的临时图) */ +export const UNSAVED_WORKFLOW_ID = '__none__' + /** 拼查询串:null / undefined / 空串跳过,数组用逗号连起来 */ function qs(params?: Record): string { if (!params) return '' @@ -213,7 +368,17 @@ function qs(params?: Record): string { return s ? `?${s}` : '' } -const get = (p: string) => request(p) +/** + * 单次请求的选项。timeoutMs 到点就掐断并按网络失败抛出(带 timeoutMs):连得上但 + * 卡着不回的接口(MCP 服务不应答时的 /tools)不能让调用方永远等下去,也不该一直 + * 占着浏览器对同一主机的那几条连接 + */ +export interface RequestOptions { + timeoutMs?: number + signal?: AbortSignal +} + +const get = (p: string, opts?: RequestOptions) => request(p, opts) const post = (p: string, body?: unknown) => request(p, { method: 'POST', body: JSON.stringify(body ?? {}) }) const patch = (p: string, body: unknown) => @@ -229,7 +394,7 @@ export const api = { // ---- 工作流 ---- workflows: { - list: () => get('/workflows'), + list: (opts?: RequestOptions) => get('/workflows', opts), get: (id: string) => get(`/workflows/${id}`), create: (body: { name: string; description?: string; graph?: GraphSpec; tags?: string[] }) => post('/workflows', body), @@ -261,23 +426,32 @@ export const api = { test: (id: string) => post(`/datasources/${id}/test`, {}), /** 测一份还没保存的配置,只测连接不落库。编辑时带上 id,后端可以沿用已存的密码 */ testConfig: (body: any) => post('/datasources/test', body), - /** 上传 Excel / CSV,变成一个可以用 SQL 查的数据源。同名就地替换 */ - uploadTable: (file: File, body: { name: string; description?: string; header_row?: number }) => { + /** 上传 Excel / CSV,变成一个可以用 SQL 查的数据源。同名就地替换。onProgress 报字节进度 */ + uploadTable: (file: File, body: { name: string; description?: string; header_row?: number }, opts?: UploadOptions) => { const form = new FormData() form.append('file', file) form.append('name', body.name) form.append('description', body.description ?? '') form.append('header_row', String(body.header_row ?? 1)) - return request<{ + type Out = { source: any; replaced: boolean tables: { name: string; sheet: string; rows: number columns: { name: string; type: string }[] }[] - }>('/datasources/upload', { method: 'POST', body: form }) + } + return opts?.onProgress + ? upload('/datasources/upload', form, opts) + : request('/datasources/upload', { method: 'POST', body: form, signal: opts?.signal }) }, introspect: (id: string, schema?: string) => post(`/datasources/${id}/introspect${schema ? `?schema=${encodeURIComponent(schema)}` : ''}`, {}), + /** 换个 schema 看看:只返回探到的结果,不覆盖缓存(Copilot 看到的还是原来那份) */ + previewSchema: (id: string, schema?: string) => + post(`/datasources/${id}/introspect${qs({ schema, dry_run: 'true' })}`, {}), schema: (id: string, table?: string) => get(`/datasources/${id}/schema${table ? `?table=${encodeURIComponent(table)}` : ''}`), + /** 一张表的结构,带结构化的列信息,不用再去解析 detail 文本 */ + tableSchema: (id: string, table: string) => + get(`/datasources/${id}/schema?table=${encodeURIComponent(table)}`), }, runs: { @@ -302,7 +476,8 @@ export const api = { /** 从失败的节点接着跑。graph 只能带改过配置的同一张图,结构必须一致 */ continue: (id: string, graph?: GraphSpec | null) => post(`/runs/${id}/continue`, { graph: graph ?? null }), - cancel: (id: string) => post<{ ok: boolean }>(`/runs/${id}/cancel`), + // 等审批的运行直接收成 cancelled;在跑的要等引擎收尾,先回 stopping + cancel: (id: string) => post<{ ok: boolean; status?: 'stopping' | 'cancelled' }>(`/runs/${id}/cancel`), resume: (id: string, response: any) => post(`/runs/${id}/resume`, { response }), events: (id: string, after = 0) => get(`/runs/${id}/events?after=${after}`), state: (id: string) => get(`/runs/${id}/state`), @@ -317,15 +492,15 @@ export const api = { approvals: { /** 老写法 list('pending') 照旧可用。status 可逗号分隔,'all' 取全部 */ - list: (params: string | { status?: string; run_id?: string; limit?: number } = 'pending') => - get(`/approvals${qs(typeof params === 'string' ? { status: params } : { status: 'pending', ...params })}`), + list: (params: string | { status?: string; run_id?: string; limit?: number } = 'pending', opts?: RequestOptions) => + get(`/approvals${qs(typeof params === 'string' ? { status: params } : { status: 'pending', ...params })}`, opts), decide: (id: string, body: { approved: boolean; note?: string; value?: any; args?: any }) => post(`/approvals/${id}/decide`, body), }, // ---- 设置 ---- providers: { - list: () => get('/providers'), + list: (opts?: RequestOptions) => get('/providers', opts), catalog: () => get('/providers/catalog'), create: (body: any) => post('/providers', body), update: (id: string, body: any) => patch(`/providers/${id}`, body), @@ -347,7 +522,7 @@ export const api = { // ---- 工具 ---- tools: { - list: () => get('/tools'), + list: (opts?: RequestOptions) => get('/tools', opts), /** 危险工具要 confirm,否则后端回 409,detail 写明它会做什么 */ run: (name: string, args: Record, opts?: { confirm?: boolean }) => post(`/tools/${name}/run`, opts?.confirm ? { args, confirm: true } : { args }), @@ -358,6 +533,11 @@ export const api = { update: (id: string, body: any) => patch(`/custom-tools/${id}`, body), remove: (id: string) => del(`/custom-tools/${id}`), test: (id: string, args: Record) => post(`/custom-tools/${id}/test`, { args }), + /** 试跑一份还没保存的配置,不落库。失败时回 {ok:false, error, hint, detail, duration_ms} */ + testDraft: (body: { + name?: string; kind: string; parameters?: Record; config?: Record + args?: Record + }) => post('/custom-tools/test', body), }, mcp: { list: () => get('/mcp/servers'), @@ -398,18 +578,26 @@ export const api = { clearScope: (scope: string) => del(`/memory/scope/${scope}`), }, kb: { - collections: () => get<{ collection: string; documents: number; chunks: number }[]>('/kb/collections'), + collections: (opts?: RequestOptions) => + get<{ collection: string; documents: number; chunks: number }[]>('/kb/collections', opts), documents: (collection?: string) => get(`/kb/documents${collection ? `?collection=${collection}` : ''}`), ingest: (body: { collection?: string; title?: string; content: string; source?: string }) => post('/kb/documents', body), - upload: (file: File, collection = 'default') => { + /** + * 上传一份文档。给了 onProgress 就报真实的字节进度(XHR);字节发完以后是后端 + * 在解析、切块,那段没有进度,调用方写「处理中」,不要把条拉满冒充完成 + */ + upload: (file: File, collection = 'default', opts?: UploadOptions) => { const form = new FormData() form.append('file', file) - return request(`/kb/upload?collection=${collection}`, { method: 'POST', body: form }) + const path = `/kb/upload?collection=${encodeURIComponent(collection)}` + return opts?.onProgress + ? upload(path, form, opts) + : request(path, { method: 'POST', body: form, signal: opts?.signal }) }, search: (q: string, collection?: string, alpha = 0.5) => - get(`/kb/search?q=${encodeURIComponent(q)}&alpha=${alpha}${collection ? `&collection=${collection}` : ''}`), + get(`/kb/search?q=${encodeURIComponent(q)}&alpha=${alpha}${collection ? `&collection=${encodeURIComponent(collection)}` : ''}`), /** 上传框能收哪些文件。以后端解析器为准,免得界面放行了后端必拒的格式 */ formats: () => get<{ extensions: string[]; text: string[]; tabular: string[]; legacy: string[]; accept: string @@ -440,12 +628,21 @@ export const api = { probeEmbedding: (baseUrl: string) => get<{ base_url: string; models: string[] }>( `/kb/embedding/probe?base_url=${encodeURIComponent(baseUrl)}`), + /** 等做完再返回。几千段配远端 embedding 要好几分钟,界面上用 startReindex */ reindex: (collection?: string) => post<{ reindexed: number; memories_reindexed: number; embedder: string }>( - `/kb/reindex${collection ? `?collection=${collection}` : ''}`), + `/kb/reindex${qs({ collection })}`), + /** + * 后台重建,立刻返回任务;进度用 reindexStatus 轮询。已经有一次在跑时回 409, + * detail 写着进度 + */ + startReindex: (collection?: string) => + post(`/kb/reindex${qs({ collection, background: 'true' })}`), + /** 最近一次重建的进度。进程里只记最近一次,还没重建过是 {state:'idle'} */ + reindexStatus: () => get('/kb/reindex'), }, skills: { - list: () => get('/skills'), + list: (opts?: RequestOptions) => get('/skills', opts), create: (body: Partial) => post('/skills', body), update: (id: string, body: Partial) => patch(`/skills/${id}`, body), remove: (id: string) => del(`/skills/${id}`), @@ -481,9 +678,15 @@ export const api = { generate: (body: { instruction: string; base_graph?: GraphSpec | null conversation_id?: string | null + /** 这一轮只查这几个数据源(id 或名字)。不传或空表示不限 */ + datasource_ids?: string[] | null }) => - post<{ graph: GraphSpec; explanation: string; issues: ValidationIssue[] }>( - '/copilot/generate', body), + post<{ + graph: GraphSpec; explanation: string; issues: ValidationIssue[] + layout?: { mode: 'keep' | 'full'; placed: string[] } + /** 改图前后都在、工具绑定却变了的节点和成员。老后端不给 */ + tool_changes?: ToolChange[] + }>('/copilot/generate', body), explain: (graph: GraphSpec) => post<{ explanation: string }>('/copilot/explain', { graph }), layout: (graph: GraphSpec) => post('/copilot/layout', { graph }), fromRun: (runId: string, name?: string) => @@ -514,7 +717,10 @@ export function streamCopilot( intent?: 'build' | 'answer' /** 属于哪次对话。带上它,这一轮才知道前面聊过什么 */ conversation_id?: string | null + /** 这一轮只查这几个数据源(id 或名字)。不传或空表示不限 */ + datasource_ids?: string[] | null }, + // final 操作里带 tool_changes(ToolChange[]):改图回执要显式列出工具绑定的变化 onOp: (op: any) => void, onEnd: (error?: string) => void, ): () => void { diff --git a/frontend/src/canvas/FlowCanvas.tsx b/frontend/src/canvas/FlowCanvas.tsx index 542853df..aa3f9bfe 100644 --- a/frontend/src/canvas/FlowCanvas.tsx +++ b/frontend/src/canvas/FlowCanvas.tsx @@ -1,27 +1,29 @@ import './surface.css' import { - createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, + createContext, memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type CSSProperties, } from 'react' import { Background, BackgroundVariant, ControlButton, Controls, MiniMap, ReactFlow, ReactFlowProvider, - useReactFlow, useStoreApi, type AriaLabelConfig, type Edge, type MiniMapNodeProps, type Viewport, + useReactFlow, useStoreApi, ViewportPortal, type AriaLabelConfig, type Edge, type MiniMapNodeProps, type Viewport, } from '@xyflow/react' -import { Crosshair, Lock, Route as RouteIcon } from 'lucide-react' +import { Crosshair, Lock, PanelRightOpen, Route as RouteIcon, Search } from 'lucide-react' import clsx from 'clsx' import { FlowEdge, MomentContext, RoutingContext, type EdgeRunState, type EdgeView } from './FlowEdge' +import { openInspector, stepsFirst, useSheet } from './InspectorSheet' import { NodeCard } from './NodeCard' +import { NODE_CATEGORIES, NODE_DEFS, type NodeDef } from './nodeDefs' import { buildRoutes, NODE_WIDTH } from './routing' import { RunTimeline, useDock } from './RunTimeline' -import { toast } from '../components/ui' +import { isComposing, toast } from '../components/ui' import { runClassLabel } from '../lib/terms' import { shortId } from '../lib/format' import { - activeEdgesOf, applyDerived, edgeIdOf, edgeKey, heldEdgesOf, topology, walkedEdges, type GraphLike, + activeEdgesOf, edgeIdOf, edgeKey, heldEdgesOf, topology, walkedEdges, type GraphLike, } from '../run/derive' import { isActivePhase, isTerminal, type NodeState, type NodeTrace, type RunPhase, type Trace, } from '../run/trace' -import { projectCached } from '../run/useNodeView' +import { replayTraceCached } from '../run/useNodeView' import { useStudio, type FlowNode } from '../store/studio' import type { NodeType } from '../types' @@ -30,6 +32,11 @@ const edgeTypes = { flow: FlowEdge } /** 读得清卡片字的最低缩放。低于它,12px 的标题落到 7px 以下,光弧也细到看不见 */ const READABLE_ZOOM = 0.6 +/** + * 精简档里标题换成放大的字(见 NodeCard 的 .nc-lod),0.4 倍时屏幕上仍有 11px 以上。 + * 整张图在可读缩放下放不下、在这里放得下时,打开就看全图的形状,而不是只看前两列 + */ +const COMPACT_ZOOM = 0.4 /** 语义缩放三档的门槛,上下各留 0.02 的回滞,免得停在门槛上来回跳 */ const LOD_FULL = 0.6 const LOD_SIGNAL = 0.35 @@ -105,31 +112,43 @@ let lastFocus: object | null = null const setStudio = useStudio.setState as (patch: Record) => void // ------------------------------------------------------------------------- -// 回放:把航迹投影到游标那一刻,边和小地图按那一刻的样子画 +// 回放:航迹投影到游标那一刻(run/useNodeView 的 replayTraceCached,卡片读同一份), +// 边、小地图、回边的圈数都按那一刻画 // ------------------------------------------------------------------------- -function replayTrace(t: Trace, at: number, graph: GraphLike): Trace { - const proj = projectCached(t, at) - const nodes: Record = {} - for (const [id, n] of Object.entries(t.nodes)) { - const p = proj.nodes[id] - const taken: string[] = [] - let looping = false - for (const s of n.segments) { - if (s.kind !== 'run' || s.start > at) continue - if (s.handle && !taken.includes(s.handle)) taken.push(s.handle) - looping = s.handle === 'body' && s.end != null && s.end <= at - } - const state = p?.state ?? 'idle' - // 人工审批的通过 / 驳回只记在节点上,不在段上:它结束了就用节点上的 - const finished = state === 'done' || state === 'failed' - const decided = taken.length ? taken : finished ? n.taken ?? [] : [] - nodes[id] = { - ...n, state, count: p?.count ?? 0, taken: decided, takenHandle: decided[decided.length - 1], - looping: state === 'running' && looping, - } +/** + * 回边兜回去了几次:目标这一次进入以来开跑的次数减一。回放截到游标。 + * 走过 done 出口的那一段是上一次进入的收尾——嵌套在外层循环里时,内层每次进来 + * 重新数,和循环卡片上「第几轮」同一个口径。审批恢复的重放不算又跑了一次 + */ +function loopsOf(n: NodeTrace | undefined, at: number | null): number { + if (!n) return 0 + let runs = 0 + for (let i = n.segments.length - 1; i >= 0; i -= 1) { + const s = n.segments[i] + if (s.kind !== 'run' || s.resumed || (at != null && s.start > at)) continue + if (s.handle === 'done' && runs > 0) break + runs += 1 } - return applyDerived({ ...t, phase: proj.phase, nodes }, graph) + return Math.max(0, runs - 1) +} + +/** + * 运行中点了一个跑过的节点:右栏滚到它的步骤。右栏归助手面板,它接这个事件 + * (在对话层时先切到运行层),接了就 preventDefault;没人接时自己找步骤行描一圈 + */ +function revealStep(nodeId: string): void { + const runId = useStudio.getState().run?.id + const handled = !window.dispatchEvent(new CustomEvent('agentlab:reveal-step', { + detail: { runId, nodeId }, cancelable: true, + })) + if (handled) return + const rows = document.querySelectorAll(`[data-step-status][data-node-id="${CSS.escape(nodeId)}"]`) + const row = rows[rows.length - 1] + if (!row) return + row.scrollIntoView({ block: 'center', behavior: reducedMotion() ? 'auto' : 'smooth' }) + row.dataset.flash = 'focus' + setTimeout(() => { if (row.dataset.flash === 'focus') delete row.dataset.flash }, 1800) } /** 每条边此刻的运行态 */ @@ -211,6 +230,160 @@ const typeColor = (n: { data?: unknown }): string => { return type ? `var(--nt-${type})` : 'var(--text-faint)' } +// ------------------------------------------------------------------------- +// Copilot 这一轮删掉的节点:原地留一道虚线框,停一下再淡出 +// ------------------------------------------------------------------------- + +/** 虚线框停留的总时长,和 surface.css 里 sf-ghost-out 的时长一致 */ +const GHOST_MS = 2400 + +interface Ghost { + id: string + x: number + y: number + w: number + h: number + label: string + type: NodeType +} + +const ghostOf = (n: FlowNode): Ghost => ({ + id: n.id, x: n.position.x, y: n.position.y, + w: n.measured?.width ?? NODE_WIDTH, h: n.measured?.height ?? 80, + label: n.data.label || NODE_DEFS[n.data.nodeType]?.label || n.id, type: n.data.nodeType, +}) + +/** + * 助手改完图,新加的节点有入场、改过的有标记,删掉的却是一声不响就没了——回执写着 + * 「删除 1」,画布上看不出删的是哪一个、原来在哪。在它原来的位置画一道虚线框、 + * 写上名字,停两秒淡出。只由这一轮落定触发、只播一次;关了动效就不画(回执里照样写着) + */ +function useRemovedGhosts(): { seq: number; items: Ghost[] } | null { + const [ghosts, setGhosts] = useState<{ seq: number; items: Ghost[] } | null>(null) + useEffect(() => { + // 这一轮开始前的样子:删掉的节点可能在流式途中就没了,落定时 store 里已经找不到 + let before = new Map() + let timer = 0 + const unsub = useStudio.subscribe((s, prev) => { + if (s.copilot.active && !prev.copilot.active) before = new Map(prev.nodes.map((n) => [n.id, ghostOf(n)])) + if (s.copilotTurns === prev.copilotTurns) return + const turn = s.copilotTurns[s.copilotTurns.length - 1] + if (!turn || turn.phase !== 'done' || turn.outcome !== 'applied') return + if (prev.copilotTurns.find((t) => t.id === turn.id)?.phase === 'done') return + const items = (turn.diff?.removed ?? []).flatMap((id) => before.get(id) ?? []) + before = new Map() + if (!items.length || reducedMotion()) return + clearTimeout(timer) + setGhosts((g) => ({ seq: (g?.seq ?? 0) + 1, items })) + timer = window.setTimeout(() => setGhosts(null), GHOST_MS) + }) + return () => { + unsub() + clearTimeout(timer) + } + }, []) + return ghosts +} + +// ------------------------------------------------------------------------- +// 双击空白处快速添加:在光标处搜类型、回车放下。和节点库读同一份节点定义 +// ------------------------------------------------------------------------- + +const QUICK_W = 248 +const QUICK_H = 312 + +function QuickAdd({ x, y, bounds, onPick, onClose }: { + /** 双击处,相对画布容器的像素 */ + x: number + y: number + bounds: { w: number; h: number } + onPick: (type: NodeType) => void + onClose: () => void +}) { + const [query, setQuery] = useState('') + const [cur, setCur] = useState(0) + const box = useRef(null) + const list = useRef(null) + const defs = useMemo(() => { + const q = query.trim().toLowerCase() + // 按节点库的分组顺序排;搜的时候名字里有的排在只是说明里提到的前面——搜「分」 + // 要的是「条件分支」,不是说明里写着「分派」的协作节点 + const all = NODE_CATEGORIES.flatMap((c) => Object.values(NODE_DEFS).filter((d) => d.category === c)) + if (!q) return all + const named = (d: NodeDef) => d.label.toLowerCase().includes(q) || d.type.includes(q) + return [...all.filter(named), ...all.filter((d) => !named(d) && d.description.toLowerCase().includes(q))] + }, [query]) + const at = Math.min(cur, Math.max(0, defs.length - 1)) + const listId = useId() + + // 点到外面就收起;它是个一次性的小面板,不该留在那儿挡画布 + useEffect(() => { + const onDown = (e: PointerEvent) => { if (!box.current?.contains(e.target as Node)) onClose() } + document.addEventListener('pointerdown', onDown, true) + return () => document.removeEventListener('pointerdown', onDown, true) + }, [onClose]) + useEffect(() => { + list.current?.querySelector(`[data-i="${at}"]`)?.scrollIntoView({ block: 'nearest' }) + }, [at]) + + const onKeyDown = (e: React.KeyboardEvent) => { + if (isComposing(e)) return + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + setCur(Math.max(0, Math.min(defs.length - 1, at + (e.key === 'ArrowDown' ? 1 : -1)))) + } else if (e.key === 'Enter') { + e.preventDefault() + if (defs[at]) onPick(defs[at].type) + } else if (e.key === 'Escape') { + // 用掉这一下:别再让它去关属性面板、清运行结果 + e.preventDefault() + e.stopPropagation() + onClose() + } + } + // 贴着光标放,放不下就往回收,不伸出画布 + const left = Math.max(8, Math.min(x, bounds.w - QUICK_W - 8)) + const top = Math.max(8, Math.min(y, bounds.h - QUICK_H - 8)) + const optId = (i: number) => `${listId}-${i}` + return ( +
+ +
+ {defs.map((d, i) => { + const Icon = d.icon + return ( +
setCur(i)} + onClick={() => onPick(d.type)}> + + {d.label} + {d.category} +
+ ) + })} + {!defs.length &&
没有叫「{query.trim()}」的节点
} +
+
↑↓ 选择 · ↵ 放在这里 · Esc 关闭
+
+ ) +} + // ------------------------------------------------------------------------- function CanvasInner() { @@ -231,6 +404,7 @@ function CanvasInner() { const selectedId = useStudio((s) => s.selectedId) const follow = useStudio((s) => s.follow) const pathOnly = useDock((s) => s.pathOnly) + const sheetOpen = useSheet((s) => s.open) // 动作从 getState 取:整个 useStudio() 解构会订阅全部字段,每个 token 都让画布重渲染一次 const actions = useStudio.getState() @@ -248,9 +422,10 @@ function CanvasInner() { const routes = useMemo(() => buildRoutes(nodes, edges), [nodes, edges]) // ---- 此刻(实时或回放游标那一刻)的航迹 ---- + // graph 直接用 store 里的 nodes / edges 数组:卡片那边按同样的引用取,缓存才对得上 const view = useMemo(() => { if (replayAt == null) return { trace, phase: runPhase, flowing: activeEdges } - const t = replayTrace(trace, replayAt, graph) + const t = replayTraceCached(trace, replayAt, graph) return { trace: t, phase: t.phase, flowing: activeEdgesOf(t, graph) } }, [trace, runPhase, activeEdges, replayAt, graph]) @@ -307,10 +482,9 @@ function CanvasInner() { return edges.map((e) => { const state = edgeStates.get(e.id) ?? 'idle' const back = topo.back.has(edgeKey(e)) - const target = view.trace.nodes[e.target] const sf: EdgeView = { state, back, rank: topo.rank[e.target] ?? 0, - loops: back ? Math.max(0, target?.iteration ?? (target?.count ?? 1) - 1) : 0, + loops: back ? loopsOf(view.trace.nodes[e.target], replayAt) : 0, } const className = clsx( `sf-e-${state}`, @@ -321,7 +495,7 @@ function CanvasInner() { // animated 一律关掉:React Flow 的 animated 是一条永远在走的虚线,方向和"是否真在流"都说不清 return { ...e, type: 'flow', className, animated: false, data: { ...e.data, sf } } }) - }, [edges, edgeStates, topo, view.trace, hasRun, selectedId, pathView]) + }, [edges, edgeStates, topo, view.trace, replayAt, hasRun, selectedId, pathView]) // ---- 节点:注入拓扑层级(一次性时刻按它错开),只看执行路径时压暗没走的 ---- // 按原节点对象缓存:拖一个节点只换那一个的副本,其余卡片的 data 引用不变,memo 生效 @@ -385,6 +559,15 @@ function CanvasInner() { }) }, [rfStore, applyZoom]) + // ---- 取景 ---- + /** + * 正在飞的那一次程序取景(跟随、点名定位、Copilot 跟镜头)。画布尺寸恰好在 + * 半路变了(运行一开始航迹坞就升起来)时,下面的保持中心会打断它;这里记着 + * 目标,打断之后重新飞一次,不然镜头停在半路、要看的节点还在视口外 + */ + const flight = useRef<{ ids: string[]; zoomAtLeast?: number; until: number } | null>(null) + const retarget = useRef<(() => void) | null>(null) + // 画布尺寸变了(航迹坞出现、变量抽屉挤进来)时保持视口中心不动。React Flow // 默认钉住左上角,坞一升起来,画面下半截的节点就被它盖住了 const box = useRef(null) @@ -398,6 +581,7 @@ function CanvasInner() { if (last.w && last.h && (w !== last.w || h !== last.h)) { const { x, y, zoom } = getViewport() void setViewport({ x: x + (w - last.w) / 2, y: y + (h - last.h) / 2, zoom }) + if (flight.current && Date.now() < flight.current.until) requestAnimationFrame(() => retarget.current?.()) } last = { w, h } }) @@ -405,7 +589,6 @@ function CanvasInner() { return () => ro.disconnect() }, [getViewport, setViewport]) - // ---- 取景 ---- const pausedUntil = useRef(0) /** * 人手动平移、缩放的那一下:开始时记下视口,结束时真的动过才让跟随让路。 @@ -426,7 +609,7 @@ function CanvasInner() { /** * 打开一张图时的取景。整图放得下、缩放不低于可读下限就整图居中;放不下就 * 不再硬塞——以入口为左锚、按可读缩放只取前几列,其余交给小地图。以前一律 - * 整图塞进来,12 节点的图缩到 0.2,标题只剩 3px。 + * 整图塞进来,12 节点的图缩到 0.2,标题只剩 3px。窄画布另有一档,见下面 narrow。 */ const frame = useCallback((duration: number) => { const el = rf.current @@ -442,16 +625,26 @@ function CanvasInner() { void fitView({ padding: 0.12, maxZoom: 1.1, minZoom: READABLE_ZOOM, duration, interpolate: LINEAR }) return } - // 按列聚类,取前四列的跨度定缩放 + // 按列聚类 const xs = [...new Set(all.map((n) => Math.round(n.position.x)))].sort((a, b) => a - b) const cols: number[] = [] for (const x of xs) if (!cols.length || x - cols[cols.length - 1] > 60) cols.push(x) - const firstCols = cols.slice(0, 4) - const span = (firstCols[firstCols.length - 1] ?? bounds.x) + NODE_WIDTH - bounds.x - let zoom = Math.min(1, Math.max(READABLE_ZOOM, (W - 2 * PAD) / Math.max(span, NODE_WIDTH))) + const spanOf = (k: number) => (cols[Math.min(k, cols.length) - 1] ?? bounds.x) + NODE_WIDTH - bounds.x + // 窄画布(1024 宽、两边栏都开着时只剩五百来 px):可读缩放下连前三列都放不下, + // 打开只看得见两张半卡片。这时退到精简档:整张图放得下就看全图,放不下就多看几列。 + // 宽画布照旧按可读缩放——精简卡没有摘要和遥测,能看清就不该让人先放大一次 + const narrow = cols.length >= 3 && spanOf(3) * READABLE_ZOOM > W - 2 * PAD + if (narrow && fit >= COMPACT_ZOOM) { + void fitView({ padding: 0.12, maxZoom: READABLE_ZOOM, minZoom: COMPACT_ZOOM, duration, interpolate: LINEAR }) + return + } + // 取前四列的跨度定缩放 + let zoom = narrow + ? Math.max(COMPACT_ZOOM, Math.min(READABLE_ZOOM, (W - 2 * PAD) / spanOf(4))) + : Math.min(1, Math.max(READABLE_ZOOM, (W - 2 * PAD) / Math.max(spanOf(4), NODE_WIDTH))) // 竖直方向放得下就别再放大:宁可多看几列 const vfit = (H - 2 * PAD) / bounds.height - if (vfit >= READABLE_ZOOM) zoom = Math.min(zoom, vfit) + if (vfit >= (narrow ? COMPACT_ZOOM : READABLE_ZOOM)) zoom = Math.min(zoom, vfit) const entries = all.filter((n) => (n.data as FlowNode['data']).nodeType === 'input') const anchor = entries[0] ?? all.reduce((a, b) => (a.position.x <= b.position.x ? a : b)) const anchorH = anchor.measured?.height ?? 80 @@ -511,9 +704,15 @@ function CanvasInner() { const fits = (maxX - minX) * z <= el.clientWidth - 2 * M && (maxY - minY) * z <= el.clientHeight - 2 * M const target = fits ? { cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 } : { cx: rects[0].x + rects[0].w / 2, cy: rects[0].y + rects[0].h / 2 } - void setCenter(target.cx, target.cy, { zoom: z, duration: reducedMotion() ? 0 : 320, interpolate: LINEAR }) + const duration = reducedMotion() ? 0 : 320 + flight.current = { ids, zoomAtLeast: opts.zoomAtLeast, until: Date.now() + duration + 80 } + void setCenter(target.cx, target.cy, { zoom: z, duration, interpolate: LINEAR }) return true }, [getInternalNode, getViewport, setCenter]) + retarget.current = () => { + const f = flight.current + if (f) bringIntoView(f.ids, { force: true, zoomAtLeast: f.zoomAtLeast }) + } // 跟随执行:有节点实时开跑、而它在视口外时平移过去。只由 node.started 驱动, // 不新增任何动画循环;同一帧里一起开跑的并成一次 @@ -541,6 +740,31 @@ function CanvasInner() { } }, [bringIntoView]) + // Copilot 搭图时镜头跟着它刚动过的节点:落在视口外就平移过去,不改缩放(新节点 + // 本来就落在上游旁边或视口中心附近的空地上)。人一旦手动平移、缩放,这一轮就不再跟; + // 节点还在滑向落点时每帧都会变,飞行途中不重新起飞,免得镜头追着它抖 + const copilotOff = useRef(false) + useEffect(() => { + let raf = 0 + const unsub = useStudio.subscribe((s, prev) => { + if (s.copilot.active && !prev.copilot.active) copilotOff.current = false + if (!s.copilot.active || !s.copilotCursor || copilotOff.current) return + if (s.copilotCursor === prev.copilotCursor && s.nodes === prev.nodes) return + if (raf) return + raf = requestAnimationFrame(() => { + raf = 0 + const st = useStudio.getState() + if (!st.copilot.active || !st.copilotCursor || copilotOff.current || userMove.current) return + if (flight.current && Date.now() < flight.current.until) return + bringIntoView([st.copilotCursor]) + }) + }) + return () => { + unsub() + cancelAnimationFrame(raf) + } + }, [bringIntoView]) + // 右栏、时间轴、胶囊请求取景:点名要看它,就算已在视口里也居中一下。 // focusRequest 每次请求都是新对象(seq +1),同一个节点连点两次也会再取景 useEffect(() => { @@ -585,6 +809,41 @@ function CanvasInner() { [actions, screenToFlowPosition, formalLock], ) + const ghosts = useRemovedGhosts() + + // ---- 双击空白处快速添加 ---- + const [quick, setQuick] = useState<{ x: number; y: number; flow: { x: number; y: number } } | null>(null) + const closeQuick = useCallback(() => setQuick(null), []) + const onPaneDoubleClick = (e: React.MouseEvent) => { + if (!(e.target as Element).classList?.contains('react-flow__pane')) return + if (formalLock) { + toast.warn('正式运行进行中,画布只读。等它结束再改') + return + } + if (useStudio.getState().copilot.active) { + toast.info('助手正在改这张图,等这一轮结束再加节点') + return + } + const r = box.current?.getBoundingClientRect() + if (!r) return + setQuick({ x: e.clientX - r.left, y: e.clientY - r.top, flow: screenToFlowPosition({ x: e.clientX, y: e.clientY }) }) + } + const pickQuick = (type: NodeType) => { + const at = quick?.flow + setQuick(null) + // 双击处是卡片的中心,不是左上角 + if (at) actions.addNode(type, { x: at.x - NODE_WIDTH / 2, y: at.y - 40 }) + } + + // ---- 运行中点节点:先看它的步骤,属性面板是次级入口 ---- + // 看运行(进行中或回放)时点了跑过的节点,属性面板不自动盖上右栏(见 InspectorSheet); + // 这里给一个明确的入口,双击节点也能开 + // 选中了却没开面板,只会是「先看步骤」那条路。运行结束后它照样留着:不然节点选中着、 + // 面板没开、入口也没了 + const peekId = selectedId && sheetOpen !== selectedId ? selectedId : null + const peekNode = peekId ? nodes.find((n) => n.id === peekId) : undefined + const peekLabel = peekNode ? peekNode.data.label || NODE_DEFS[peekNode.data.nodeType]?.label || peekNode.id : '' + const rootStyle = useMemo( () => (moment?.kind === 'failed' ? { '--moment-origin': moment.origin } as CSSProperties : undefined), [moment], @@ -619,22 +878,42 @@ function CanvasInner() { onNodesChange={actions.onNodesChange} onEdgesChange={actions.onEdgesChange} onConnect={actions.onConnect} - onNodeClick={(_, node) => actions.select(node.id)} + onNodeClick={(_, node) => { + if (!stepsFirst(node.id)) { + // 点的是已经选中、面板却没开的节点(先看过步骤、运行随后结束了): + // 选中不变就不会触发面板的订阅,这里直接点名打开 + openInspector(node.id) + return + } + actions.select(node.id) + revealStep(node.id) + }} + onNodeDoubleClick={(_, node) => openInspector(node.id)} onPaneClick={() => actions.select(null)} + onDoubleClick={onPaneDoubleClick} + zoomOnDoubleClick={false} onNodeMouseEnter={(_, node) => { if (hasRun) useDock.getState().set({ canvasHover: node.id }) }} onNodeMouseLeave={() => { if (hasRun) useDock.getState().set({ canvasHover: null }) }} onMoveStart={(event, vp) => { // 程序取景(跟随、聚焦)没有 event;有 event 的是人在拖、在滚轮 if (event) userMove.current = vp }} - onMoveEnd={(_, vp) => { + onMoveEnd={(event, vp) => { applyZoom(vp.zoom, true) + // 只认人手的那一下收尾。程序取景飞到一半被人抓住时,d3 先收掉那段动画, + // React Flow 却把它的收尾推迟到下一拍才报——那时人的拖动已经开始,这条 + // 不带 event 的收尾要是也来结账,就把人的起点吃掉了,拖完跟随照样不暂停 + if (!event) return const from = userMove.current if (!from) return userMove.current = null const moved = Math.abs(vp.x - from.x) > 2 || Math.abs(vp.y - from.y) > 2 || Math.abs(vp.zoom - from.zoom) > 1e-3 - if (moved && active) pauseFollow() + if (!moved) return + // 人接过了镜头:程序取景不再续飞,Copilot 这一轮也不再跟 + flight.current = null + if (useStudio.getState().copilot.active) copilotOff.current = true + if (active) pauseFollow() }} onDrop={onDrop} onDragOver={(e) => { @@ -674,6 +953,16 @@ function CanvasInner() { style={{ width: 150, height: 100 }} ariaLabel={hasRun ? '小地图(按运行状态着色)' : '小地图'} /> + {ghosts && ( + + {ghosts.items.map((g) => ( +
+ 已删除 · {g.label} +
+ ))} +
+ )} @@ -681,7 +970,7 @@ function CanvasInner() { {/* 画布顶上的浮条排成一行:跟随在左,只读横幅居中;放不下时横幅折到下一行、 再不够就截掉说明文字。各自绝对定位的话,笔记本宽度下横幅会盖住「恢复」 */} - {(formalLock || showFollow) && ( + {(formalLock || showFollow || peekId) && (
{showFollow && (
@@ -698,6 +987,14 @@ function CanvasInner() { )}
)} + {peekId && ( + + )} {formalLock && (
@@ -707,6 +1004,11 @@ function CanvasInner() { )}
)} + + {quick && box.current && ( + + )}
) } diff --git a/frontend/src/canvas/FlowEdge.tsx b/frontend/src/canvas/FlowEdge.tsx index 5eaf8785..1fd12229 100644 --- a/frontend/src/canvas/FlowEdge.tsx +++ b/frontend/src/canvas/FlowEdge.tsx @@ -28,7 +28,7 @@ export interface EdgeView { back: boolean /** 目标节点的拓扑层级,一次性时刻按它错开 */ rank: number - /** 回边已经兜回去几次(循环第几轮)。0 不显示 */ + /** 回边已经兜回去几次:这一次进入循环以来,回放截到游标。0 不显示 */ loops?: number } diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index 68ab5b33..e100a4b8 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -1,11 +1,12 @@ import { useEffect, useId, useMemo, useState } from 'react' import { createPortal } from 'react-dom' -import { AlertTriangle, ChevronLeft, Copy, Maximize2, Plus, Trash2, X, XCircle } from 'lucide-react' +import { AlertTriangle, ChevronLeft, Copy, Lock, Maximize2, Plus, Trash2, X, XCircle } from 'lucide-react' import clsx from 'clsx' import { NODE_DEFS, syntaxOf, type FieldDef, type FieldSyntax } from './nodeDefs' -import { fieldOfIssue, type FieldRef } from './issues' +import { fieldOfIssue, unboundToolOf, withToolBound, type FieldRef } from './issues' import { hintOf } from './shortcuts' -import { useStudio } from '../store/studio' +import { isActivePhase } from '../run/trace' +import { EDIT_LOCK_TEXT, useEditLock, useStudio } from '../store/studio' import { useCatalog, modelOptions } from '../store/catalog' import { api } from '../api/client' import { IconButton, JsonInput, Modal, isComposing } from '../components/ui' @@ -35,12 +36,61 @@ function useRunDefaults() { return value } +interface ToolOption { value: string; label: string; hint?: string; danger?: boolean; group?: string } + +/** + * 数据源的查询工具(db_query__<源> / db_schema__<源>)。/tools 的列表只有内置、自定义和 MCP, + * 以前检查器里挑不到它们:校验说「提示词要求用 db_query__x,把它加进工具里」,界面上却做不到。 + * 30 秒内复用上一次的结果:每点一个节点都去问一遍没必要,可刚在数据页加的源也不能一直看不见 + */ +let sourceTools: { at: number; list: Promise } | null = null +function useSourceTools(): ToolOption[] { + const [value, setValue] = useState([]) + useEffect(() => { + if (!sourceTools || Date.now() - sourceTools.at > 30_000) { + sourceTools = { + at: Date.now(), + list: api.datasources.list().then((rows) => rows + .filter((r) => r && r.enabled !== false) + .flatMap((r) => (Array.isArray(r.tools) ? r.tools as string[] : []).map((t) => ({ + value: t, label: t, group: '数据源', + hint: `${t.startsWith('db_schema__') ? '查表结构' : '执行 SQL'} · 数据源「${r.name}」`, + // 可写的源上,写语句要人确认(运行时的审批关卡认它) + danger: t.startsWith('db_query__') && r.readonly === false, + })))) + .catch(() => { sourceTools = null; return [] }), + } + } + let alive = true + void sourceTools?.list.then((v) => { if (alive) setValue(v) }) + return () => { alive = false } + }, []) + return value +} + +/** 节点能绑的全部工具:目录里的,加上数据源的 */ +function useToolOptions(): ToolOption[] { + const tools = useCatalog((s) => s.tools) + const sources = useSourceTools() + return useMemo(() => { + const known = new Set(tools.map((t) => t.id)) + return [ + ...tools.map((t) => ({ value: t.id, label: t.name, hint: t.description, danger: t.dangerous, group: t.category })), + ...sources.filter((o) => !known.has(o.value)), + ] + }, [tools, sources]) +} + /** 属性面板。所有字段都由 nodeDefs 的声明驱动渲染,加节点类型不用改这里。 */ export function Inspector() { const selectedId = useStudio((s) => s.selectedId) const node = useStudio((s) => s.nodes.find((n) => n.id === s.selectedId)) const allIssues = useStudio((s) => s.issues) - const locked = useStudio((s) => s.copilot.active) + // 助手在改、正式运行在跑:整块只读,说清为什么 + const lock = useEditLock() + const locked = lock != null + // 运行中(探索运行也算)不删节点:画布那边同样关了删除键,事件会对到一个已经不在的节点上 + const running = useStudio((s) => isActivePhase(s.runPhase)) const runtime = useStudio((s) => (selectedId ? s.runtime[selectedId] : undefined)) // 动作逐个取:不带选择器的 useStudio() 订阅整个 store,运行时每来一个 token 这块面板都要重渲染 const updateNode = useStudio((s) => s.updateNode) @@ -96,12 +146,24 @@ export function Inspector() { {def.label} duplicateNode(node.id)} icon={} /> - removeNode(node.id)} icon={} /> - {/* 助手改图期间整块只读:这时候改的东西会被它的最终结果悄悄覆盖 */} + {/* 助手改图、正式运行期间整块只读:前者改的东西会被它的最终结果悄悄覆盖,后者跑的是 + 已发布的不可变版本 */}
+ {lock && ( +
+ + + 只能看,不能改 + · {EDIT_LOCK_TEXT[lock]} + +
+ )}
{def.description}
{!!loose.length && ( @@ -182,8 +244,10 @@ function Header({ onBack, children }: { onBack: () => void; children?: React.Rea ) } -/** 一条问题。字段下面用行内版,面板顶部用带框的版本 */ -function IssueLine({ issue, boxed }: { issue: Pick; boxed?: boolean }) { +/** 一条问题。字段下面用行内版,面板顶部用带框的版本。action 是就地修好它的那个按钮 */ +function IssueLine({ issue, boxed, action }: { + issue: Pick; boxed?: boolean; action?: React.ReactNode +}) { const err = issue.level === 'error' const Icon = err ? XCircle : AlertTriangle return ( @@ -196,11 +260,30 @@ function IssueLine({ issue, boxed }: { issue: Pick - {issue.message} + {/* 修法放在话的下面:检查器只有三百来像素宽,并排的话这句要折成七八行 */} + + {issue.message} + {action && {action}} + ) } +/** + * 「提示词要求用 X,但没绑定」的就地修法:把 X 加进这一栏。返回新 config,已经绑了就是 null + * (按钮不出现)。修法和问题面板那一行的「绑定」是同一个 withToolBound + */ +function bindFix(issue: FieldIssue, config: Record, apply: (next: Record) => void) { + const tool = unboundToolOf(issue.message) + const next = tool ? withToolBound(config, issue.at, tool) : null + if (!tool || !next) return null + return ( + + ) +} + // ------------------------------------------------------------------------- /** @@ -268,7 +351,7 @@ function Field({ field, nodeId, value, config, issues, onChange }: { )} {!!own.length && (
- {own.map((i, k) => )} + {own.map((i, k) => onChange(c[field.key]))} />)}
)} @@ -282,6 +365,7 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ }) { const catalog = useCatalog() const defaults = useRunDefaults() + const toolOptions = useToolOptions() const aria = { 'aria-invalid': invalid || undefined, 'aria-describedby': describedBy } switch (field.type) { @@ -401,9 +485,7 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ ) case 'tools': { - const options = catalog.tools.map((t) => ({ - value: t.id, label: t.name, hint: t.description, danger: t.dangerous, group: t.category, - })) + const options = toolOptions // tool 节点只选一个,agent 节点可以多选 if (field.help?.includes('只能选一个')) { return ( @@ -437,7 +519,7 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ return case 'agents': - return + return default: return onChange(e.target.value)} /> @@ -608,12 +690,14 @@ function MultiPick({ options, value, onChange, empty }: { ) } -function Row({ children, onRemove, removeLabel, tone }: { +function Row({ children, onRemove, removeLabel, tone, item }: { children: React.ReactNode; onRemove: () => void; removeLabel: string tone?: 'error' | 'warning' + /** 第几项。问题面板定位时按 [data-field] [data-item] 滚到这一项 */ + item?: number }) { return ( -
{children}
@@ -653,7 +737,7 @@ function IoFieldList({ nodeId, value, issues, onChange }: { {value.map((field, i) => { const own = issues.filter((x) => x.at?.index === i) return ( - x.level === 'error') ? 'error' : own.length ? 'warning' : undefined} onRemove={() => onChange(value.filter((_, idx) => idx !== i))}> @@ -775,7 +859,7 @@ function CaseList({ nodeId, mode, value, issues, onChange }: { : keyIssue || own.length ? 'warning' : undefined const condBad = own.some((x) => x.at?.sub === 'condition' && x.level === 'error') return ( - onChange(value.filter((_, idx) => idx !== i))}>
@@ -848,7 +932,7 @@ function MetricList({ nodeId, value, issues, onChange }: { {value.map((m, i) => { const own = issues.filter((x) => x.at?.index === i) return ( - x.level === 'error') ? 'error' : own.length ? 'warning' : undefined} onRemove={() => onChange(value.filter((_, idx) => idx !== i))}>
@@ -886,45 +970,65 @@ function MetricList({ nodeId, value, issues, onChange }: { ) } -function AgentList({ value, onChange }: { value: any[]; onChange: (v: any[]) => void }) { - const catalog = useCatalog() +/** + * 协作成员。成员自己的问题(提示词点名的工具没绑、角色设定写得不对)落在那个成员的那一栏下面: + * 以前它们既不在成员行里、也不在面板顶上,问题面板说有错,点过来却什么都看不见 + */ +function AgentList({ value, issues, config, onChange }: { + value: any[]; issues: FieldIssue[]; config: Record; onChange: (v: any[]) => void +}) { + const options = useToolOptions() const uid = useId() const update = (i: number, patch: any) => onChange(value.map((a, idx) => (idx === i ? { ...a, ...patch } : a))) + const fix = (x: FieldIssue) => bindFix(x, config, (c) => onChange(c.agents)) return (
- {value.map((agent, i) => ( - onChange(value.filter((_, idx) => idx !== i))}> -
- - update(i, { name: e.target.value })} /> + {value.map((agent, i) => { + const own = issues.filter((x) => x.at?.index === i) + const at = (sub: string) => own.filter((x) => x.at?.sub === sub) + const rest = own.filter((x) => x.at?.sub !== 'system' && x.at?.sub !== 'tools') + return ( + x.level === 'error') ? 'error' : own.length ? 'warning' : undefined} + onRemove={() => onChange(value.filter((_, idx) => idx !== i))}> +
+ + update(i, { name: e.target.value })} /> + + + update(i, { max_steps: e.target.value === '' ? undefined : Number(e.target.value) })} /> + +
+ + update(i, { description: e.target.value })} /> - - update(i, { max_steps: e.target.value === '' ? undefined : Number(e.target.value) })} /> - -
- - update(i, { description: e.target.value })} /> - - {/* 成员的 system 后端直接取值、不过模板渲染:这里不挂补全,免得教人写 {{ }} */} - -