Why: failed or ungrounded material notices still asked nontechnical users to choose deliverables and could act on invented model options. Evidence: failure, partial, no-suggestion, forged-marker, source-drift, reload and host-option RED→GREEN tests; race, isolated PostgreSQL, full Backend vet and tests pass. Impact: only a completed source-bound result with 3-4 bounded suggestions can offer a choice; failed or stale notices preserve truthful status and block unsolicited delegation. Refs: U3 r0.4 design; internal/runmgr/material_notice.go; internal/runtime/material_choice_notice.go
269 lines
12 KiB
Go
269 lines
12 KiB
Go
package runtime
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
"agent-native/runtime/gates"
|
||
agentllm "agent-native/runtime/llm"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
const verifyCapNotice = "本轮回复未通过终轮检查。"
|
||
|
||
const verifierUnavailableNotice = "本轮未做同源模型验证(验证器未独立配置);硬事实与事实核查已执行"
|
||
|
||
const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。根据用户要求、终轮回复、工具证据和工件清单判断这一轮是否已经完成。
|
||
同一对象的后一次检查覆盖前一次,以最后一次为准。内置质量检查已通过时,不要用更早的工具输出推翻它。
|
||
拿不准时 passed 必须为 false。只输出一个 JSON 对象,不要写其他文字:{"passed":false,"reason":"为什么没通过","nextAction":"主模型下一步该做什么"}。`
|
||
|
||
// systemCheckReason 把终轮检查写成系统提醒。内核会把它当成一条用户消息送回模型,
|
||
// 不用这层包裹的话,模型会以为是用户自己提出的意见。
|
||
func systemCheckReason(body string) string {
|
||
body = strings.TrimSpace(body)
|
||
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实并修复指出的问题。检查带位置时先修那一处;确认检查本身有误时,在最终答复里用一句话说明原因(不要引用检查原文),然后如实收尾——既不要默默无视未通过的检查,也不要在没核实的情况下反复空转。\n" + body + "\n</system-reminder>"
|
||
}
|
||
|
||
// materialBackstopReason 材料整理兜底(附件即保证)的续跑指令:不走
|
||
// systemCheckReason 的「核实修复」前缀——这里没有待修的检查,只有事实告知
|
||
// 与职责提醒;ensureMaterial 幂等保证只续跑一轮。
|
||
const materialBackstopReason = "<system-reminder>\n这是系统的终轮自动处理,不是用户发言。系统已自动启动材料整理后台任务(收件箱有进度卡「材料整理」,完成后结果自动回投并汇报)。请在当前回复里告知用户这一点(一两句话即可),并补充你现在就能给的即时反馈——每份材料一句话概览或初步判断。不要用 delegate_task 重复派发材料整理,也不要等待整理完成再回复。\n</system-reminder>"
|
||
|
||
// materialNoticeMarker 材料整理完成通知的稳定标记:runmgr 的通知正文自然
|
||
// 含此串;runtime 依此识别「本回合负有发起交付物选择卡的职责」。
|
||
const materialNoticeMarker = "「材料整理」已结束"
|
||
|
||
// deliverableBackstopReason 用户已选定交付物但后台任务未启动的硬性续跑指令。
|
||
const deliverableBackstopReason = "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。用户已经在选择卡上做出决定,但对应的后台任务还没有启动。立即调用 delegate_task(background=true)启动它:任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点;启动后用一句话向用户确认即可,不要等待完成,也不要再次询问用户。\n</system-reminder>"
|
||
|
||
// materialNoticeBackstopReason 材料整理通知回合未发起选择卡的应急指令
|
||
// (弱模型不发 confirm_choice 时兜底,一次性)。
|
||
const materialNoticeBackstopReason = "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。当前已完成的材料整理有3-4项经过宿主提取的建议,但还没有让用户选择正式交付物。立即只从最近一条材料整理系统通知中的这些建议调用 confirm_choice,附上各自的材料依据,允许自由输入;不要编造默认选项。发起后简短收尾即可。\n</system-reminder>"
|
||
|
||
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) {
|
||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||
stream := l.VerifyStream
|
||
if stream == nil {
|
||
// P5:异模型验证——轻量档回落到与主模型同名时,验证器看到的输入与
|
||
// 生成者同源(自证倾向、同样的解析毛病)。此时跳过模型验证,让
|
||
// 硬事实/事实核查承担终轮检查,比假独立更诚实。
|
||
chat := ResolveChatModel(rc.Profile(), rc.Settings())
|
||
sameModel := strings.EqualFold(strings.TrimSpace(route.Model), strings.TrimSpace(chat.Model)) &&
|
||
strings.EqualFold(strings.TrimSpace(route.Base), strings.TrimSpace(chat.Base))
|
||
if !rc.Settings().GetBool("runtime.verifierSameModel", false) && sameModel {
|
||
return gates.Verdict{}, fmt.Errorf("验证器未配置独立轻量模型(当前与主模型相同),跳过同源验证")
|
||
}
|
||
if route.Model == "" || route.Base == "" {
|
||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||
}
|
||
client := openLLMClient(route.Base, route.Key, route.Model)
|
||
// 验证器不经内核循环:显式接重试(WP6 同映射)与 5xx 回退链。
|
||
stream = agentllm.WithFallback(
|
||
agentllm.WithRetry(client.Stream, retryAttemptsOf(rc.Settings()), nil),
|
||
ChatFallbacks(rc.Settings()), nil)
|
||
}
|
||
model := route.Model
|
||
if model == "" {
|
||
model = "verify"
|
||
}
|
||
msgs := []agentllm.Message{
|
||
{Role: agentllm.RoleSystem, Content: l.verifyPrompt(rc.Profile().Owner)},
|
||
{Role: agentllm.RoleUser, Content: evidencePrompt(currentUserText(rc), body, evidence, refs)},
|
||
}
|
||
var b strings.Builder
|
||
for ev := range stream(rc.Context(), model, msgs, nil, agentllm.StreamOptions{}) {
|
||
switch ev.Kind {
|
||
case agentllm.KindTextDelta:
|
||
b.WriteString(ev.Text)
|
||
case agentllm.KindError:
|
||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||
case agentllm.KindMessageEnd:
|
||
if b.Len() == 0 {
|
||
b.WriteString(ev.Message.Content)
|
||
}
|
||
}
|
||
}
|
||
verdict, err := gates.ParseVerdict(b.String())
|
||
if err != nil {
|
||
return gates.Verdict{}, fmt.Errorf("验证器返回无法解析")
|
||
}
|
||
return verdict, nil
|
||
}
|
||
|
||
func (l *LLMRunner) verifyPrompt(owner string) string {
|
||
if l.Prompts != nil {
|
||
if body, ok, err := l.Prompts.ModuleBody(owner, "aux.verify.final"); err == nil && ok && strings.TrimSpace(body) != "" {
|
||
return body
|
||
}
|
||
}
|
||
return verifyFallbackPrompt
|
||
}
|
||
|
||
func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.ArtifactRef) string {
|
||
var b strings.Builder
|
||
b.WriteString("用户要求:\n")
|
||
if strings.TrimSpace(user) == "" {
|
||
b.WriteString("(无)\n")
|
||
} else {
|
||
b.WriteString(user)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString("\n终轮回复:\n")
|
||
b.WriteString(body)
|
||
b.WriteString("\n\n本轮工具(按时间编号。同一对象的后一次检查覆盖前一次,以最后一次为准):\n")
|
||
if len(evidence) == 0 {
|
||
b.WriteString("(无)\n")
|
||
}
|
||
for i, ev := range evidence {
|
||
text := ev.VerifyText
|
||
if text == "" {
|
||
text = ev.Summary
|
||
}
|
||
fmt.Fprintf(&b, "#%d %s %s %s\n", i+1, ev.Name, ev.Status, text)
|
||
}
|
||
b.WriteString("\n本轮工件:\n")
|
||
if len(refs) == 0 {
|
||
b.WriteString("(无)\n")
|
||
}
|
||
for _, ref := range refs {
|
||
if ref == nil {
|
||
continue
|
||
}
|
||
fmt.Fprintf(&b, "- %s %s\n", ref.ArtifactID, ref.Name)
|
||
}
|
||
b.WriteString("\n内置质量检查:上面列出的每个文件的最新版本均已检查:存在且非空。")
|
||
if len(refs) > 0 {
|
||
b.WriteString("文本类工件(md、txt、docx)的版式与内容评分也已通过;若某项检查未实际运行,会在结论里单独标注「自检未运行」。这是强证据,不要用更早的工具输出推翻它。")
|
||
}
|
||
b.WriteString("\n")
|
||
return b.String()
|
||
}
|
||
|
||
func (st *directRun) loadChecklist() {
|
||
if src, ok := st.rc.(interface{ Todos() (core.TodoList, bool) }); ok {
|
||
if list, ok := src.Todos(); ok {
|
||
st.todos = list
|
||
}
|
||
}
|
||
if src, ok := st.rc.(interface {
|
||
Plan() (core.PlanSnapshot, bool)
|
||
}); ok {
|
||
if p, ok := src.Plan(); ok {
|
||
st.plan = p
|
||
}
|
||
}
|
||
}
|
||
|
||
func (st *directRun) reminders(turn int) []string {
|
||
if st.closedToolTurn {
|
||
if st.checklistTouched {
|
||
st.toolTurnsSince = 0
|
||
} else {
|
||
st.toolTurnsSince++
|
||
}
|
||
st.closedToolTurn = false
|
||
st.checklistTouched = false
|
||
}
|
||
// 任务快慢双速(SR-B10 实路径触发):run 已运行超过阈值、用户还没看到任何
|
||
// 答复、也没把深度工作放后台时,一次性提醒转双速——先把判断/计划写给用户,
|
||
// 重活 delegate 后台。已经回复或已委派则永不打扰;接近完成的任务可按文案
|
||
// 自行忽略。这是内核每回合的 Reminders 注入点(agent-native 循环原生钩子)。
|
||
if note := st.dualSpeedNudge(); note != "" {
|
||
return []string{note}
|
||
}
|
||
// E4(R4):harness 检测到的换路条件优先于计划提醒——原地打转时
|
||
// 计划提醒没有意义。
|
||
if notes := st.escalationReminders(); len(notes) > 0 {
|
||
return notes
|
||
}
|
||
if st.toolTurnsSince < gates.ReminderToolGap {
|
||
return nil
|
||
}
|
||
todos, plans := st.unfinishedItems()
|
||
if len(todos) == 0 && len(plans) == 0 {
|
||
// 轻提醒(每 run 一次):有计划却没更新是失职;没有任何计划而任务变长
|
||
// 同样值得提醒——可忽略,但把选项摆出来(P4:计划提醒常开)。
|
||
if !st.nudgedPlanless {
|
||
st.nudgedPlanless = true
|
||
st.toolTurnsSince = 0
|
||
return []string{"本次任务已连续多个工具轮没有任何计划。如果剩余工作不止一步,用 update_plan 列出步骤再继续;确实只剩简单收尾就忽略本条。"}
|
||
}
|
||
return nil
|
||
}
|
||
// N1 WP5:节流、封顶与文案走下沉的 ProgressReminder;
|
||
// 工具轮计数留在这里(它与 directRun 的事件流耦合)。
|
||
if st.progress == nil {
|
||
st.progress = &gates.ProgressReminder{}
|
||
}
|
||
got := st.progress.Remind(todos, plans, time.Now())
|
||
if got != nil {
|
||
st.toolTurnsSince = 0
|
||
}
|
||
return got
|
||
}
|
||
|
||
// dualSpeedNudgeSeconds:双速提醒的运行时长阈值(秒)。0 = 关闭。
|
||
const dualSpeedNudgeDefaultSeconds = 75
|
||
|
||
// 双速提醒升级:首次被无视后每 45 秒重复、最多 3 次,第二次起改硬性措辞。
|
||
// 实测 MiniMax 对单次 system-reminder 无动于衷(opus 一次即响应)——重复+升级是
|
||
// 不改内核契约的前提下最强的请求侧手段;仍无效的模型记录到任务卡,后续考虑
|
||
// 系统级强制拆分(cancel + 后台续跑)方案。
|
||
const (
|
||
dualSpeedRepeatEvery = 45 * time.Second
|
||
dualSpeedMaxNudges = 3
|
||
)
|
||
|
||
// dualSpeedNudge 返回双速提醒文案;不满足条件或已达重复上限返回空串。
|
||
// 条件用注入时钟(l.now)计量,测试可控。
|
||
func (st *directRun) dualSpeedNudge() string {
|
||
after := int(st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds))
|
||
if after <= 0 || st.replied || st.delegatedBackground {
|
||
return ""
|
||
}
|
||
if st.started.IsZero() {
|
||
return ""
|
||
}
|
||
elapsed := st.l.now().Sub(st.started)
|
||
if elapsed < time.Duration(after)*time.Second {
|
||
return ""
|
||
}
|
||
due := 1 + int((elapsed-time.Duration(after)*time.Second)/dualSpeedRepeatEvery)
|
||
if due > dualSpeedMaxNudges {
|
||
due = dualSpeedMaxNudges
|
||
}
|
||
if st.nudgedDualSpeedCount >= due {
|
||
return ""
|
||
}
|
||
st.nudgedDualSpeedCount = due
|
||
secs := int(elapsed.Round(time.Second).Seconds())
|
||
log.Printf("[runtime] dual-speed nudge #%d injected at %ds (replied=%v delegated=%v)", due, secs, st.replied, st.delegatedBackground)
|
||
if due == 1 {
|
||
return fmt.Sprintf("本次任务已连续运行约 %d 秒,用户还没有收到任何答复(任务快慢双速,SR-B10)。现在二选一:若剩余工作量大(预计还要一分钟以上),先把你的判断、概要或执行计划作为即时反馈回复给用户,同时用 delegate_task 的 background=true 把深度处理放到后台执行(收件箱有进度卡,完成后系统自动回投结果并汇报);若已接近完成,把这句话忽略,继续做完后在最终答复里给出完整结论。不要默默长时间工作。", secs)
|
||
}
|
||
return fmt.Sprintf("用户已经等待约 %d 秒(第 %d 次提醒,硬性要求):立即停止后续工具调用,把当前进展、结论或计划作为回复发给用户;剩余的深度处理用 delegate_task 的 background=true 放到后台执行,完成后系统会自动回投结果。不要再让用户在没有反馈的情况下等待。", secs, due)
|
||
}
|
||
|
||
// unfinishedItems 返回计划与待办的未完成项正文(状态过滤留在产品侧)。
|
||
func (st *directRun) unfinishedItems() (todos, plans []string) {
|
||
for _, item := range st.todos.Items {
|
||
if item.Status == "done" {
|
||
continue
|
||
}
|
||
if text := strings.TrimSpace(item.Text); text != "" {
|
||
todos = append(todos, text)
|
||
}
|
||
}
|
||
for _, step := range st.plan.Steps {
|
||
if step.Status == "done" || step.Status == "skipped" {
|
||
continue
|
||
}
|
||
if text := strings.TrimSpace(step.Text); text != "" {
|
||
plans = append(plans, text)
|
||
}
|
||
}
|
||
return todos, plans
|
||
}
|