mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
d18a496ef9
为什么:一轮问数据以前是浏览器在推(store/chat.ts 的 plan / launch / settle):网络一抖建图那条流就断、建了一半的图丢掉;
发起运行、复核、重来、落库都要页面在,页面一关这一轮就停在 running,15 分钟后才被判成断了(总方案 §0 的调查结论)。
做了什么:
- 流程和评测共用一份:eval/answer.Driver 里的流程抽到 chat/drive.TurnDriver,差异写成子类给的几项(生成的请求、目录视图、
改图、入口、发起参数、停在审批上怎么办、复核等多久)。评测的 Driver 成了它的子类,行为和测试一字不变(test_eval_* 照跑)。
- chat/job.py:问数据的一轮是 core/job_runtime 上的后台任务(不占数据源的队,并发归「交互」类,第 2 波再给上限和优先级)。
停在审批上照旧等人(评测是取消);复核最多等 25 秒,晚到的那次用量照记(review.late);按复核自动重来一次;用户点的
重试本轮、重新运行、放宽步数、跑一下、继续运行、接着收尾都是同一轮里的一次尝试,上一次的结局归档进 meta.attempts。
- 事件日志(新表 chat_turn_events,models_chat.py,不进运行的封存范围):每一步按 seq 落库再叫醒订阅方,订阅方按 seq
回库里读——回放和实时是同一段代码;心跳一拍也回库里看一次,第 2 波多进程时别的进程写的最迟一拍送到。
- 接口 api/chat_turns.py:POST /api/chat/turns(202 带 turn_id)、GET /{id}、GET /{id}/stream?after=(SSE:先回放、再推
实时、带心跳、结束补 stream.end)、POST /{id}/cancel。挂 chat_scope,每个接口按这一轮所属的对话核对归属;访问密钥调不了。
- conversation_turns 那一行由服务端写(chat/record.py:状态、运行 id、答案、复核、meta 各项和页面以前写的一一对应);失败
只记原因(meta.cause),说法留在界面层按 lib/explain 讲。TurnOut 带这一轮的任务(job),列表判「还在跑」先看任务。
- 使用者的问数据运行规则(白名单、自己的记忆范围、遮罩、只读)抽到 chat/launch.user_chat_run,POST /api/runs 和服务端的
一轮共用,说法一字不变。
- 服务重启:还在建图的标中断(可重试本轮);已发起运行的接着等那次运行、照常复核收尾;和调度循环同一个开关,拷来的数据
目录启动时不替人收尾,页面打开对话时再请服务端接着收尾(mode=resume)。老轮次停在建图中的同样标中断。
- 删对话时先停掉它正在跑的几轮(取消各自的运行),任务和事件日志一并删。
测试:tests/test_chat_turns.py——一轮跑完不依赖页面、直接回答与反问、开流前被拒、实时流回放后接实时与按 seq 重连(补齐、
不重复)、思考合并、建图中与运行中停止、重启时建图中标中断与已发起运行的接着收尾、开关关着时等人来接、等审批后收尾、
按复核重来、用户重试归档、一段对话一次一轮、继续运行被拒照 /runs 的说法、删对话停掉这一轮、使用者的归属与遮罩只读、
访问密钥调不了、驳回与失败与发起被拒的原因、晚到的复核、跑一下与重新运行不重建。去掉几处关键实现回跑,相应的测试都会失败。
167 lines
10 KiB
Python
167 lines
10 KiB
Python
"""问数据一轮里的几个「决定」(细案 01 做法 1,总方案 2.1)。
|
||
|
||
一轮问数据是:建图(助手的生成过程,api/copilot.answer_ops)→ 要不要运行 → 运行(输入怎么填)→ 复核 → 要不要带着原图
|
||
重来一次、重来时说什么。以前这几个决定都写在问数据页里(frontend/src/store/chat.ts 的 plan / launch / settle / retry),
|
||
后端跑不了一轮问数据:评测要么照着抄一份(迟早走样,量出来的不是用户拿到的那一轮),要么做不了;这也违反「界面层不放
|
||
逻辑」。现在决定写在这里,是纯函数:
|
||
|
||
- next_step:生成过程交出结局之后做什么(含生成过程一个操作都没给时自动重来一次);
|
||
- fill_input:用户的问题填进这张图的入口字段;
|
||
- retry_after_review:复核之后要不要带着原图重来一次;
|
||
- retry_instruction:重来时交给助手的那句话(两种措辞)。
|
||
|
||
接口把决定交给问数据页:/copilot/generate-stream 的 final 带 next(run | ready | fail),error 带 next(retry | fail),
|
||
/copilot/review 的结果带 retry_instruction(要重来时是那句话,不重来是 null)。问数据页照后端给的走;老后端没有这两个字段时退回它自己那份同样的
|
||
规则(tests/test_chat_turn_policy.py 逐条对照两边的常量和措辞)。评测(第 3 波)用同一组函数把一轮串起来。
|
||
|
||
整轮编排后来也搬到了服务端(docs/design/orchestration-round §1):流程在 chat/drive.py(问数据和评测共用),问数据的后台任务在
|
||
chat/job.py;页面只发起、只看,不再自己判这些。老服务端那条退路(页面自己那份规则)随之去掉。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from collections.abc import Mapping
|
||
from typing import Any
|
||
|
||
#: 一轮最多自动试几次(含第一次):第二次还不行多半是问题本身没说清,该让用户介入(同 chat.ts 的 MAX_ATTEMPTS)
|
||
MAX_ATTEMPTS = 2
|
||
|
||
#: final 之后的三种走法(final.next):运行、只建不跑(摆着等用户点运行)、判失败(没过检查的图不拿去跑)
|
||
NEXT_RUN, NEXT_READY, NEXT_FAIL = "run", "ready", "fail"
|
||
NEXT_STEPS = (NEXT_RUN, NEXT_READY, NEXT_FAIL)
|
||
#: 别的结局:直接回答了(reply)、助手反问了等用户答(asked)、重来一次(retry:没自查过的老服务端交来没过检查的图,或者
|
||
#: 生成过程一个操作都没给)
|
||
NEXT_REPLY, NEXT_ASKED, NEXT_RETRY = "reply", "asked", "retry"
|
||
|
||
#: 生成过程一个操作都没给就结束了(error 操作的 code):模型只写了思考(deepseek 这类推理模型偶发)、或者格式跑偏每一行都被
|
||
#: 跳过。这是偶发的,重来一次多半就好,按「重来一次」的规矩自动重来(最多 MAX_ATTEMPTS 次,含第一次);被截断的不重来——额度
|
||
#: 不变,重来还是截断
|
||
EMPTY_GENERATION = "copilot_empty"
|
||
#: 自动重来的 error(code)
|
||
RETRY_ERRORS = frozenset({EMPTY_GENERATION})
|
||
#: error 操作的 message 前缀(api/copilot.answer_ops):重来时交给助手的原因不带它
|
||
_ERROR_PREFIX = "助手本轮未完成:"
|
||
|
||
#: 问题往哪个入口字段放:名字像「问题」的优先(同 chat.ts fillInput 的正则,大小写不敏感)
|
||
QUESTION_FIELD = re.compile(r"question|query|topic|input|问题|需求|主题", re.IGNORECASE)
|
||
|
||
#: 带着原图重来时交给助手的话:原图交回去改(调大步数、改参数这种事就是改一个字段,不是推倒重来)
|
||
RETRY_WITH_BASE = ("这个工作流上一次运行没有成功:{reason}。\n\n"
|
||
"请针对这个原因改它(比如调大最大步数、改正工具参数、换个查询方式),"
|
||
"改完继续回答原来的问题:{question}")
|
||
#: 没有图可交回(上一次连图都没建出来)时:原问题后面缀上原因,从零再建
|
||
RETRY_FROM_SCRATCH = "{question}\n\n(上一次没有成功:{reason}。请针对这个原因调整,重新给出完整的工作流。)"
|
||
#: 用户点「重试本轮」时交给助手的话(以前在问数据页 retryTurn 里拼,一轮搬到服务端之后在这里):有图时说的是「没有给出可用的
|
||
#: 结果」——上一次未必是运行失败(停止了、被驳回、结论不可用都算),原因是那一次的归档说法(chat/record.attempt_reason);
|
||
#: 没有图的同 RETRY_FROM_SCRATCH
|
||
RETRY_USER_WITH_BASE = ("这个工作流上一次没有给出可用的结果:{reason}。\n\n"
|
||
"请针对这个原因改它(比如调大最大步数、改正工具参数、换个查询方式),"
|
||
"改完继续回答原来的问题:{question}")
|
||
|
||
|
||
def _blockers(final: Mapping[str, Any]) -> list[str]:
|
||
return [str(i.get("message") or "") for i in final.get("issues") or []
|
||
if isinstance(i, Mapping) and i.get("level") == "error"]
|
||
|
||
|
||
def next_step(terminal: Mapping[str, Any] | None, *, self_checked: bool = True) -> str:
|
||
"""生成过程交出结局之后做什么。terminal 是最后那条结局操作(final / reply / error);流结束时只有反问(ask)、没有
|
||
别的结局的给 None。
|
||
|
||
- reply:直接回答,这一轮到此为止;只有反问:等用户回答;
|
||
- error:生成过程一个操作都没给(code 是 EMPTY_GENERATION)时重来一次(retry,次数由调用方按 MAX_ATTEMPTS 管,原因
|
||
取 error_reason),别的判失败;
|
||
- final:有 error 级问题时,服务端已经自查过(收到过 check 的结论)就判失败——没过的问题已经交回模型在同一轮里改过
|
||
了,再整轮重来是再烧一次完整生成、改的还是同一批问题;没自查过(老服务端)才重来一次(retry);模型说不运行
|
||
(autorun=false:用户要的是流程本身、或者问了用户)只建不跑;其余运行。
|
||
"""
|
||
if terminal is None:
|
||
return NEXT_ASKED
|
||
kind = terminal.get("op")
|
||
if kind == "reply":
|
||
return NEXT_REPLY
|
||
if kind == "error":
|
||
return NEXT_RETRY if terminal.get("code") in RETRY_ERRORS else NEXT_FAIL
|
||
if kind != "final":
|
||
return NEXT_FAIL
|
||
if _blockers(terminal):
|
||
return NEXT_FAIL if self_checked else NEXT_RETRY
|
||
if terminal.get("autorun") is False:
|
||
return NEXT_READY
|
||
return NEXT_RUN
|
||
|
||
|
||
def error_reason(error: Mapping[str, Any]) -> str:
|
||
"""生成过程交出 error 时重来的原因(交给助手的那句话里用):error 带的 reason,没有就是 message 去掉「助手本轮未完成:」。"""
|
||
reason = str(error.get("reason") or "").strip()
|
||
if reason:
|
||
return reason
|
||
message = str(error.get("message") or "").strip()
|
||
return message[len(_ERROR_PREFIX):] if message.startswith(_ERROR_PREFIX) else message
|
||
|
||
|
||
def blocked_reason(final: Mapping[str, Any]) -> str:
|
||
"""没过检查的图重来时的原因(只有老服务端那条路用得到)。"""
|
||
return "生成的工作流没有通过校验:" + ";".join(_blockers(final))
|
||
|
||
|
||
def fill_input(graph: Mapping[str, Any] | None, question: str, history: str = "") -> dict[str, Any]:
|
||
"""用户的问题填进这张图的入口字段(同 chat.ts 的 fillInput)。
|
||
|
||
入口没声明字段:{question, history}(下游可能用 input.question)。声明了:每个字段先填默认值(没有就空串),问题放进
|
||
名字像「问题」的那个,其次第一个必填的,再不然第一个;history(前几轮压成的文字)照样带上——图跑起来时模型得知道
|
||
「它们」指的是上一轮那批数据。
|
||
"""
|
||
nodes = (graph or {}).get("nodes") or []
|
||
entry = next((n for n in nodes if isinstance(n, Mapping) and n.get("type") == "input"), None)
|
||
config = ((entry or {}).get("data") or {}).get("config") or {}
|
||
fields = [f for f in (config.get("fields") or []) if isinstance(f, Mapping)]
|
||
if not fields:
|
||
return {"question": question, "history": history}
|
||
payload: dict[str, Any] = {"history": history}
|
||
for f in fields:
|
||
if f.get("name"):
|
||
payload[str(f["name"])] = f["default"] if f.get("default") is not None else ""
|
||
named = next((f for f in fields if QUESTION_FIELD.search(str(f.get("name") or ""))), None)
|
||
target = named or next((f for f in fields if f.get("required")), None) or fields[0]
|
||
if target.get("name"):
|
||
payload[str(target["name"])] = question
|
||
return payload
|
||
|
||
|
||
def review_reason(review: Mapping[str, Any]) -> str:
|
||
"""复核判不可用时重来的原因:各条信号的说明,没有就用给用户看的那句说明。"""
|
||
details = [str(s.get("detail") or "") for s in review.get("signals") or [] if isinstance(s, Mapping)]
|
||
return ";".join(d for d in details if d) or str(review.get("note") or "")
|
||
|
||
|
||
def retry_after_review(review: Mapping[str, Any] | None, attempt: int) -> bool:
|
||
"""复核之后要不要带着原图重来一次:复核说换个配置重跑大概率就好(retry)、答案不可信(severity=broken)、这一轮还没
|
||
试满(attempt 从 0 数,attempt + 1 < MAX_ATTEMPTS)。"""
|
||
if not review:
|
||
return False
|
||
return bool(review.get("retry")) and review.get("severity") == "broken" and attempt + 1 < MAX_ATTEMPTS
|
||
|
||
|
||
def retry_instruction(question: str, reason: str, has_base: bool) -> str:
|
||
"""重来时交给助手的话。has_base:有上一次建出的图可以交回去改。"""
|
||
template = RETRY_WITH_BASE if has_base else RETRY_FROM_SCRATCH
|
||
return template.format(question=question, reason=reason)
|
||
|
||
|
||
def user_retry_instruction(question: str, reason: str, has_base: bool) -> str:
|
||
"""用户点「重试本轮」时交给助手的话。has_base:有上一次建出的图可以交回去改。"""
|
||
template = RETRY_USER_WITH_BASE if has_base else RETRY_FROM_SCRATCH
|
||
return template.format(question=question, reason=reason)
|
||
|
||
|
||
def has_nodes(graph: Any) -> bool:
|
||
"""一张图有没有节点(有才交回去改)。"""
|
||
return isinstance(graph, Mapping) and bool(graph.get("nodes"))
|
||
|
||
|
||
__all__ = ["EMPTY_GENERATION", "MAX_ATTEMPTS", "NEXT_ASKED", "NEXT_FAIL", "NEXT_READY", "NEXT_REPLY", "NEXT_RETRY",
|
||
"NEXT_RUN", "NEXT_STEPS", "QUESTION_FIELD", "RETRY_ERRORS", "RETRY_FROM_SCRATCH", "RETRY_USER_WITH_BASE",
|
||
"RETRY_WITH_BASE", "blocked_reason", "error_reason", "fill_input", "has_nodes", "next_step", "retry_after_review",
|
||
"retry_instruction", "review_reason", "user_retry_instruction"]
|