Files
weichen 80faf3edbd Resolve squad routes from the run owner and project
Why: F13 - the squad factory read the default account with an empty
project, pairing the run user's model name with another account's
endpoint and credentials; direct and squad paths disagreed on
reasoning split and route contract.
Evidence: app/runtime/squadadapter tests pass (verify-u10b.json);
run-scoped snapshot, project override, profile override and
fail-closed cases added with synthetic keys.
Impact: squad execution now uses the same effective-config rules as
the direct path; default setups unchanged.
Refs: feature/agent-stack-audit-remediation-b1 U10
2026-10-21 16:15:03 +00:00

269 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 := OpenRouteClient(route, rc.Settings())
// 验证器不经内核循环:显式接重试(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
}