Files
Agent-System-Backend/internal/runtime/verify.go
T
weichen 4df81ef1da
ci / vet-and-test (pull_request) Successful in 43s
Enable parallel tool execution, fix delegate truncation, sandbox deps layer
Why: Real runs were fully serial: the direct path never set the kernel's
parallel tool mode, delegate_task handed the parent only the child's
160-char summary (the full text was dropped), MiniMax streamed its
reasoning inside content <think> blocks, and the EXT-16 sandbox hid
~/.local so python-docx/openpyxl/pptx could not even import. All four
were measured defects (F1/F2/F3/F5) in the delegation/routing proposal.

Why: Serial-only mutual exclusion is not enough for order-sensitive
tools - the card-stream test failed with out-of-order appends - so the
ordering moved into the kernel (SerialTools partition, Agent-Native
#44); process events pair by CallID (#42) and tool results write back
through the injected call context (#43).

Evidence: go test ./... green, including new regression anchors:
TestDelegateToolReturnsFullText, TestToolEventsPairByCallID,
TestToolExecModeAndSerialSet, TestOpenLLMClientReasoningSplitAuto,
TestSandboxArgvDepsLayer, TestExecEnvJoinsPythonPath.

Impact: Direct runs now execute independent same-turn calls
(searches, reads, delegate_task, review_findings) concurrently by
default (runtime.parallelTools=false restores sequential); card flow,
plans, workflows, exec primitives and file edits keep model-declared
order. delegate_task returns the capped full child text. MiniMax-*
models get reasoning_split=true so thinking streams via
reasoning_content. --exec-deps-dir/AGENT_SYSTEM_EXEC_DEPS_DIR mounts a
read-only dependency layer into the sandbox and merges PYTHONPATH.
Bumps deps/agent-native to c348441.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E1,
F1-F5); Agent-Native #42/#43/#44; Dev task feature/agent-e1-parallel-fixes.
2026-10-10 09:02:10 +00:00

198 lines
7.2 KiB
Go
Raw 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"
"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>"
}
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
}
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
}
// 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
}