mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:02:01 +00:00
6b68961c65
部署上画布助手连着几轮报「助手本轮未完成:模型没有给出任何修改,也没有说明原因」,又试了一次才成。本地用同一网关、同一模型、同一底图和需求重放复现:模型思考里写着「先输出 look」,正文却是一段 DeepSeek 的 DSML 工具调用(invoke name="look",参数 need、targets),意图和参数都对,只是不是约定的单行 JSON。读流只认 JSON 和原生工具调用,于是一个操作都没认出、整轮报错;画布不处理 next=retry,点重试原样再发,几率不变。提示词里「输出这一行就停下,系统把结果交回来」很像工具调用,却从没说这里没有工具调用接口。
- engine/toolcalls:take_leaked_calls 读写成文字的工具调用(DSML 竖线一到两个、带不带空格,<function_calls><invoke>,<tool_call>{name, arguments},DeepSeek 特殊 token 两种排法),按出现先后交出已写完的调用和没写完的那一段;final 时参数写完整、只差结尾的补上结尾再读,写到一半的不硬读。_MARKUP 补认 DSML 结尾标签和 function_calls / tool_calls 包装(agent 节点的识别跟着受益)。has_markup 只认标记本身
- copilot_gen._iter_ops:从有标记的那一行起攒,一次调用写完就按名字读成操作(名字是操作名的才认,规矩同原生工具调用 _call_op;op 写在最前,接着写时交回模型的是约定写法),读到 look 照样停下去查看;读不出的记「工具调用标记的名字不认识 / 参数解析不了 / 没写完」;raw 交出正文开头一段
- Generation:起草时一次调用读完、到这时还没有能落地的操作(plan、look 不算——查看完接着写什么都没认出的整轮照样是空的),把原话交回去、附 StreamDiagnosis.correction 那句纠正,当场重写一次(FORMAT_NUDGE_PROMPT,每轮一次);什么都没写的把纠正接在最后那条消息后面。诊断按次收(absorb),记 nudged。写成标记被读出、当场纠正都打 info 日志
- 报错说法:Generation.empty 默认用 StreamDiagnosis.headline(写成工具调用格式 / 只写了思考 / 什么都没有输出 / 格式不对 / 只说了打算),不再一律「也没有说明原因」;摘要注明当场纠正过
- 提示词:look 用法(四个入口共用)和画布、应用、蓝图的输出格式都写明这里没有工具调用接口
- 问数据失败原因 meta.cause(kind=copilot)带上 code、diagnostics
验证:本地真模型重放,修后 DSML 没再出现、都正常交付;把第一次调用换成读不出的 DSML 后交给真模型,都被当场纠正救回来(先 look 再出操作);原先抓到的真实 DSML 原文离线读成 look。测试:新增 tests/test_copilot_markup_ops.py(各写法、流式切碎、和 JSON 行混写、读不出的诊断、DSML look 驱动真查看、说明文字 / 只写思考 / 只写 plan / 查看后空续写的纠正、只纠正一次、画布流不报错、报错说法、提示词);空生成相关的旧测试按「一次生成先纠正一次」更新(基线夹具 no_ops 的说法)。后端全量通过。
259 lines
14 KiB
Python
259 lines
14 KiB
Python
"""问数据一轮落进 conversation_turns 的那一行:服务端写(状态、运行 id、答案、复核、meta),页面只读。
|
||
|
||
以前这一行是页面写的(frontend/src/store/chat.ts 的 persist / buildMeta),一轮的编排搬到服务端之后由这里写,字段和页面以前
|
||
写的一一对应,读的一方(问数据页的 restoreTurn、助手拼历史的 copilot._history_section、列表的 conversations.last_status)不用变:
|
||
|
||
- status:running | done | error;error 列是一句原话(运行的报错、助手的说法、「已取消」「服务重启,本轮已中断」),助手拼
|
||
历史时照它写「这一轮失败了:…」;
|
||
- answer:答案正文(成果各字段拼起来,同页面的 answerText,上限 ANSWER_CAP);复核改写了的存改写后的;
|
||
- review:复核原样(含 verdict=ok、usage、retry_instruction);没复核过是 {};
|
||
- meta(v=1):runId、runClass、runStatus、issuance(瘦过的出具信息)、noQuery、outcome(cancelled | suspended)、rejected
|
||
(驳回的人、哪一步、备注:run.cancelled 的原样)、cause(失败的原因,见下)、attempts(用户点重试之前的各次)、ms、queries、
|
||
scope、asked、looks、evidence、usage({copilot, review, earlier, run})、at。
|
||
|
||
**失败只记原因(cause),不记说法。** 页面以前记的是翻成人话之后的 failure(标题、原因、怎么办:lib/explain 那一套),说法在
|
||
界面层;服务端只记发生了什么,页面恢复时照同一套 lib/explain 讲(frontend/src/store/chat.ts 的 failureFromCause):
|
||
{kind: refused, message, code} 开流之前就被拒(模型没配好、限定的数据源都不在)
|
||
{kind: copilot, message, hint, detail, code?, diagnostics?} 生成过程交出 error(助手本轮未完成;一个操作都没给的带
|
||
code=copilot_empty 和诊断 copilot_gen.StreamDiagnosis.as_dict,画布轮次同样记)
|
||
{kind: blocked, issues, self_checked} 生成的图没过检查
|
||
{kind: start, message, code, status} 发起运行被拒(绑定的工具不在、使用者用不了的节点)
|
||
{kind: run, error, detail} 运行失败
|
||
{kind: gone} 运行记录不见了
|
||
{kind: build_interrupted} 建图时服务重启,没有可恢复的断点
|
||
{kind: crash, message} 这一轮的后台任务自己出错
|
||
老轮次的 meta.failure 照旧认(页面优先读它)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
from collections.abc import Mapping
|
||
from typing import Any
|
||
|
||
#: 落库答案的上限(同页面的 ANSWER_CAP):只是防跑飞,不是显示上限
|
||
ANSWER_CAP = 20_000
|
||
ANSWER_CLIPPED = "\n\n…(内容过长,已截断,完整内容请查看运行记录)"
|
||
#: 归档的旧答案存多少(同页面的 ATTEMPT_ANSWER_CAP)
|
||
ATTEMPT_ANSWER_CAP = 4000
|
||
#: 停止、服务重启挂起时 error 列里的那一句(同页面落库时写的)
|
||
CANCELLED = "已取消"
|
||
SUSPENDED = "服务重启,本轮已中断"
|
||
#: 建图时服务重启
|
||
BUILD_INTERRUPTED = "服务重启,本轮在生成工作流时中断"
|
||
GONE = "运行记录不见了"
|
||
#: 用量里记的几项(同页面的 USAGE_KEYS)
|
||
USAGE_KEYS = ("cost_usd", "unpriced_calls", "calls", "unfinished_calls", "input_tokens", "output_tokens")
|
||
#: 出具信息里无法回指的数字最多存几个(同页面的 slimIssuance)
|
||
ISSUANCE_UNMATCHED_CAP = 20
|
||
|
||
|
||
def answer_text(output: Mapping[str, Any] | None) -> str:
|
||
"""成果压成一句话(同页面的 answerText):下划线开头的、空的不要;字符串原样,别的按 JSON;超过上限截断并说出来。"""
|
||
if not output:
|
||
return ""
|
||
parts = [value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||
for key, value in output.items() if not str(key).startswith("_") and value not in (None, "")]
|
||
text = "\n".join(parts)
|
||
return text[:ANSWER_CAP] + ANSWER_CLIPPED if len(text) > ANSWER_CAP else text
|
||
|
||
|
||
def slim_issuance(issuance: Any) -> dict[str, Any] | None:
|
||
"""出具信息存进 meta 前瘦一下身(同页面的 slimIssuance):无法回指的数字只留前 20 个。"""
|
||
if not isinstance(issuance, Mapping) or not issuance.get("tier"):
|
||
return None
|
||
out = dict(issuance)
|
||
unmatched = issuance.get("unmatched_numbers")
|
||
if isinstance(unmatched, list):
|
||
out["unmatched_numbers"] = unmatched[:ISSUANCE_UNMATCHED_CAP]
|
||
return out
|
||
|
||
|
||
def has_evidence(output: Mapping[str, Any] | None) -> bool:
|
||
"""成果带不带逐段证据(同页面 lib/evidence 的 hasEvidence):_evidence 或它的 others 里有文档、有字段。"""
|
||
ev = (output or {}).get("_evidence") if isinstance(output, Mapping) else None
|
||
if not isinstance(ev, Mapping):
|
||
return False
|
||
refs = [ev, *[r for r in ev.get("others") or [] if isinstance(r, Mapping)]]
|
||
return any(isinstance(r.get("doc_artifact"), str) and r.get("doc_artifact") and isinstance(r.get("fields"), list)
|
||
and r.get("fields") for r in refs)
|
||
|
||
|
||
def _names(value: Any) -> list[str]:
|
||
return [str(v) for v in value if v not in (None, "")] if isinstance(value, list) else []
|
||
|
||
|
||
def _num(value: Any) -> float | None:
|
||
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
||
|
||
|
||
def look_event(op: Mapping[str, Any]) -> dict[str, Any] | None:
|
||
"""助手的 look 操作 → 存进 meta.looks 的那一份(同页面 run/decode 的 lookEventOf):下一轮服务端据此写「上一轮查看过」。"""
|
||
if op.get("op") != "look":
|
||
return None
|
||
listed = op.get("objects") if isinstance(op.get("objects"), list) else op.get("sources")
|
||
if not isinstance(listed, list):
|
||
return None
|
||
objects = []
|
||
for x in listed:
|
||
if not isinstance(x, Mapping):
|
||
continue
|
||
kind = x.get("kind") if x.get("kind") in ("capability", "workflow_version", "caliber") else "table"
|
||
total, explored = _num(x.get("total")), _num(x.get("explored"))
|
||
obj: dict[str, Any] = {"kind": kind}
|
||
if isinstance(x.get("source"), str) and x.get("source"):
|
||
obj["source"] = x["source"]
|
||
if isinstance(x.get("source_id"), str) and x.get("source_id"):
|
||
obj["source_id"] = x["source_id"]
|
||
obj.update(named=_names(x.get("named")), picked=_names(x.get("picked")), joined=_names(x.get("joined")),
|
||
missing=_names(x.get("missing")))
|
||
if total is not None:
|
||
obj["total"] = total
|
||
if explored is not None and explored < total:
|
||
obj["explored"] = explored
|
||
if _num(x.get("catalog_tables")) is not None:
|
||
obj["catalog_tables"] = x["catalog_tables"]
|
||
if _names(x.get("added_to_scope")):
|
||
obj["added_to_scope"] = _names(x.get("added_to_scope"))
|
||
if _num(x.get("prefiltered")) is not None:
|
||
obj["prefiltered"] = x["prefiltered"]
|
||
objects.append(obj)
|
||
out: dict[str, Any] = {"op": "look", "id": str(op.get("id") or ""), "by": "system" if op.get("by") == "system" else "model",
|
||
"need": str(op.get("need") or ""),
|
||
"status": op.get("status") if op.get("status") in ("index", "failed") else "ok"}
|
||
if isinstance(op.get("reason"), str) and op.get("reason"):
|
||
out["reason"] = op["reason"]
|
||
out["elapsed_ms"] = _num(op.get("elapsed_ms")) or 0
|
||
out["objects"] = objects
|
||
return out
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 用量:{copilot, review, earlier, run},每份是 UsageTotal(同页面的 TurnUsage)
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def usage_total(usage: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
||
"""一份用量(providers/usage 的汇总、运行的 usage)只留要记的几项。不是对象交回 None。"""
|
||
return add_total({}, usage) if isinstance(usage, Mapping) else None
|
||
|
||
|
||
def add_total(a: Mapping[str, Any] | None, b: Mapping[str, Any] | None) -> dict[str, Any]:
|
||
"""两份用量相加(缺的当 0,同页面的 addUsage):只记不为 0 的项,金额保留 6 位。"""
|
||
out = {k: v for k, v in (a or {}).items() if k in USAGE_KEYS}
|
||
for key in USAGE_KEYS:
|
||
try:
|
||
value = float((b or {}).get(key) or 0)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if value:
|
||
out[key] = (out.get(key) or 0) + value
|
||
for key in USAGE_KEYS:
|
||
if key in out and key != "cost_usd":
|
||
out[key] = int(out[key])
|
||
if "cost_usd" in out:
|
||
out["cost_usd"] = round(float(out["cost_usd"]), 6)
|
||
return out
|
||
|
||
|
||
def retire_run(usage: Mapping[str, Any] | None) -> dict[str, Any]:
|
||
"""当前这次运行换下来了(自动重来、用户点重试):它的用量并进 earlier,这一轮的花费不丢这一笔(同页面的 retireRun)。"""
|
||
out = dict(usage or {})
|
||
run = out.pop("run", None)
|
||
if run:
|
||
out["earlier"] = add_total(out.get("earlier"), run)
|
||
return out
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 用户点重试:上一次的结局归档
|
||
# --------------------------------------------------------------------------
|
||
|
||
#: 归档那一次的一句话(同页面以前 attemptOf 的写法)。也是重试时交给助手的原因
|
||
ATTEMPT_TEXT = {
|
||
"cancelled": "已手动停止",
|
||
"suspended": "服务重启,运行中断",
|
||
"rejected": "被驳回",
|
||
"unusable": "结论不可用:{note}",
|
||
"partial": "存在缺口:{note}",
|
||
"done": "已给出答案",
|
||
}
|
||
|
||
|
||
def _rejected_line(rejected: Mapping[str, Any]) -> str:
|
||
"""被驳回的那一句(同页面 REJECTED_TEXT.sub):谁在哪一步驳回、备注;页面记过 sub 的照用。"""
|
||
if isinstance(rejected.get("sub"), str) and rejected["sub"]:
|
||
return rejected["sub"]
|
||
who = str(rejected.get("actor") or "").strip()
|
||
label = str(rejected.get("label") or "").strip()
|
||
where = f"「{label}」" if label else "这一步"
|
||
note = str(rejected.get("note") or "").strip()
|
||
if rejected.get("decision") == "expired_reject":
|
||
return f"{where}超过时限,按设置驳回"
|
||
return (f"{who} 驳回了{where}" if who else f"{where}被驳回") + (f":{note}" if note else "")
|
||
|
||
|
||
def attempt_record(row: Any, meta: Mapping[str, Any], *, n: int, at_ms: int | None = None) -> dict[str, Any]:
|
||
"""这一轮现在的样子折成一条留档(同页面以前的 attemptOf):{n, outcome, summary, detail?, runId, answer?, at, cause?}。
|
||
|
||
用户主动重试不能把上一次抹掉:那一次的答案可能只是有缺口、并非全错,新的一次也未必更好,两份要能对照。"""
|
||
from app.api.conversations import turn_run_id
|
||
|
||
review = row.review if isinstance(row.review, Mapping) else {}
|
||
answer = row.answer or ""
|
||
if len(answer) > ATTEMPT_ANSWER_CAP:
|
||
answer = answer[:ATTEMPT_ANSWER_CAP] + "…"
|
||
base: dict[str, Any] = {"n": n, "at": at_ms if at_ms is not None else int(time.time() * 1000),
|
||
"runId": turn_run_id(row)}
|
||
if answer:
|
||
base["answer"] = answer
|
||
signals = "\n".join(str(s.get("detail") or "") for s in review.get("signals") or [] if isinstance(s, Mapping)) or None
|
||
outcome = meta.get("outcome")
|
||
if outcome == "cancelled" and isinstance(meta.get("rejected"), Mapping):
|
||
sub = _rejected_line(meta["rejected"])
|
||
return {**base, "outcome": "rejected", "summary": ATTEMPT_TEXT["rejected"] + (f":{sub}" if sub else "")}
|
||
if outcome == "cancelled" or (row.status == "error" and (row.error or "").strip() == CANCELLED):
|
||
return {**base, "outcome": "cancelled", "summary": ATTEMPT_TEXT["cancelled"]}
|
||
if outcome == "suspended":
|
||
return {**base, "outcome": "suspended", "summary": ATTEMPT_TEXT["suspended"]}
|
||
if row.status == "error":
|
||
failure = meta.get("failure") if isinstance(meta.get("failure"), Mapping) else None
|
||
line = (f"{failure.get('title')}:{failure.get('reason')}" if failure and failure.get("reason")
|
||
else str(failure.get("title")) if failure and failure.get("title") else (row.error or "发生错误"))
|
||
out = {**base, "outcome": "failed", "summary": line}
|
||
if failure and failure.get("detail"):
|
||
out["detail"] = str(failure["detail"])
|
||
if isinstance(meta.get("cause"), Mapping):
|
||
out["cause"] = dict(meta["cause"])
|
||
return out
|
||
note = str(review.get("note") or "")
|
||
if review.get("severity") == "broken":
|
||
return {**base, "outcome": "unusable", "summary": ATTEMPT_TEXT["unusable"].format(note=note), "detail": signals}
|
||
if review.get("severity") == "degraded":
|
||
return {**base, "outcome": "partial", "summary": ATTEMPT_TEXT["partial"].format(note=note), "detail": signals}
|
||
return {**base, "outcome": "done", "summary": ATTEMPT_TEXT["done"]}
|
||
|
||
|
||
def attempt_reason(record: Mapping[str, Any]) -> str:
|
||
"""重试时交给助手的原因(同页面以前的写法):那一次的一句话,有细节的括号里带前 300 字。"""
|
||
summary = str(record.get("summary") or "")
|
||
detail = record.get("detail")
|
||
return f"{summary}({str(detail)[:300]})" if detail else summary
|
||
|
||
|
||
def with_max_steps(graph: Mapping[str, Any], steps: int) -> dict[str, Any]:
|
||
"""图里每个 agent 节点的步数上限调到 steps(「放宽步数重跑」,同页面的 withMaxSteps)。"""
|
||
nodes = []
|
||
for node in graph.get("nodes") or []:
|
||
if isinstance(node, Mapping) and node.get("type") == "agent":
|
||
data = dict(node.get("data") or {})
|
||
data["config"] = {**dict(data.get("config") or {}), "max_steps": steps}
|
||
node = {**node, "data": data}
|
||
nodes.append(node)
|
||
return {**dict(graph), "nodes": nodes}
|
||
|
||
|
||
__all__ = ["ANSWER_CAP", "ATTEMPT_ANSWER_CAP", "ATTEMPT_TEXT", "BUILD_INTERRUPTED", "CANCELLED", "GONE", "SUSPENDED",
|
||
"USAGE_KEYS", "add_total", "answer_text", "attempt_reason", "attempt_record", "has_evidence", "look_event",
|
||
"retire_run", "slim_issuance", "usage_total", "with_max_steps"]
|