fix(agent): 空转回合注入续跑指令,复用空响应重试次数

模型有时一整轮只输出思考、既不给正文也不调工具,harness 视为自然结束
(end_turn 且无 tool_use),直接以 completed + 空总结收场,一条未完成的意图
断在半路,表现为「任务正常结束但没有文字总结」。五层 LLM 重试均够不着它
(非错误路径),SDK 空响应重试也够不着(以是否 yield 过事件判空,而思考增量
本身就是事件)。

work 的 Stop 钩子现识别这种回合并注入一条续跑指令,让模型带着已有思考继续
执行下一步——不选原样重发,因这类空转由提示词与上下文形状决定,是稳定行为
而非随机抽风。次数复用 LLM 页「空响应重试次数」(默认 2、-1 关闭),是一条
意图的总量闸而非连续次数(harness 已限死连续空转只推一次)。
This commit is contained in:
Autumn-27 committed 2026-09-10 10:49:11 -04:00
1 parent eb5290039a
commit 3c2a60fba7
4 files changed
+255 -4

No files matched your search

+76 -4
View File
@@ -527,9 +527,52 @@ func (e *Engine) drainSteer(intentID int64) (string, bool) {
// before each tool call it drains a queued course-correction (if any) and blocks the
// call, handing the message back to the model — which re-plans its next step instead
// of running the tool. No queued message → the guard behaves exactly as before.
// 它同时负责「空转回合」的续跑,见 Stop。
type steerHooks struct {
inner harness.HookRunner
drain func() (string, bool)
// nudges 是本条意图已注入的空转续跑次数,上限 limit。指针:harness 持有的是
// steerHooks 的值拷贝,计数必须共享同一份。
nudges *atomic.Int64
// limit 是空转续跑的次数上限,由 Engine.emptyTurnNudgeLimit() 从「空响应重试
// 次数」解析而来。<=0 = 不介入(用户显式关掉了这层)。
limit int
// label 形如 "worker-1 · #42",只用于日志。
label string
}
// 空转回合(只有思考、既无正文也无工具调用)续跑次数的默认值,与 SDK 空响应重试的
// 内置默认(norma/llm/openai.go 的 emptyResponseRetries)保持一致——两层共用同一个
// 旋钮,不配置时的行为也该对齐。解析见 Engine.emptyTurnNudgeLimit。
//
// 注意这个数是「一条意图的总量」,不是「连续几次」:harness 自身的 stopHookActive
// 已经限死了连续空转只推一次——推完那一轮若还是空转,Stop 钩子不会再被调到,run 直接
// 收场;只有真正发生过一次工具回合,配额才刷新(norma/harness/query.go:534)。所以这个
// 闸挡的是「工具 → 空转 → 推 → 工具 → 空转」这种病态循环,别让它把意图预算耗光。
const defaultEmptyTurnNudges = 2
// emptyTurnNudge 是空转回合注入的续跑指令。
//
// 这种回合在 harness 眼里是一次自然结束(stop_reason=end_turn 且无 tool_use),五层
// LLM 重试一层都不适用——它不是错误,是模型「想完了但没动手」。SDK 的空响应重试也
// 够不着:它以「有没有 yield 过事件」判空,而思考增量本身就是事件(norma/llm/openai.go
// 的 SEThinkingDelta),所以 thinking-only 不算空。何况那层是原样重发整个 prompt,
// 对这种由上下文形状决定的空转,重发只会让模型再想一遍。这里换成追加一条指令,让它
// 带着已经产出的思考继续,输入变了才有理由给出不同的行为。
const emptyTurnNudge = "【空转提醒】你上一轮只输出了思考过程,既没有给出正文回复,也没有调用任何工具," +
"这一轮等于没有产出。请直接执行你刚才想好的下一步:要么调用工具,要么给出结论文字。不要重复思考。"
// isThinkingOnlyTurn reports whether the latest assistant turn produced neither
// text nor a tool call — i.e. the model spent the whole round thinking.
func isThinkingOnlyTurn(messages []llm.Message) bool {
for i := len(messages) - 1; i >= 0; i-- {
m := messages[i]
if m.Role != llm.RoleAssistant {
continue
}
return strings.TrimSpace(m.Text()) == "" && len(m.ToolUses()) == 0
}
return false
}
func (h steerHooks) PreToolUse(ctx context.Context, name string, input []byte) (bool, string, []byte) {
@@ -549,11 +592,31 @@ func (h steerHooks) PostToolUse(ctx context.Context, name string, input, result
}
}
// Stop 在 guard 原有语义之上补一层「空转回合」续跑:模型只输出了思考、既没给正文
// 也没调工具时,harness 会把它当成自然结束并以空 summary 收场(query.go 的
// ReasonCompleted + asst.Text()),一条本来还没做完的意图就这样断在半路。此时注入
// 一条续跑指令,让模型带着已有思考接着走。
func (h steerHooks) Stop(ctx context.Context, messages []llm.Message) (bool, []string, string) {
var (
prevent bool
blocking []string
msg string
)
if h.inner != nil {
return h.inner.Stop(ctx, messages)
prevent, blocking, msg = h.inner.Stop(ctx, messages)
}
return false, nil, ""
// inner 已经决定硬停、或已经要注入自己的续跑消息 → 尊重它,不再叠加。
// limit<=0 = 用户把「空响应重试次数」配成了 -1,即显式关掉这层。
if prevent || len(blocking) > 0 || h.nudges == nil || h.limit <= 0 || !isThinkingOnlyTurn(messages) {
return prevent, blocking, msg
}
n := h.nudges.Add(1)
if n > int64(h.limit) {
log.Printf("[work %s] 空转回合(仅思考、无正文无工具)已达续跑上限 %d,放行收场", h.label, h.limit)
return prevent, blocking, msg
}
log.Printf("[work %s] 空转回合(仅思考、无正文无工具),注入续跑指令 (%d/%d)", h.label, n, h.limit)
return false, []string{emptyTurnNudge}, ""
}
// KillWork cancels the in-flight work running intentID (planner's kill_work tool).
@@ -956,8 +1019,17 @@ func (e *Engine) runIntent(ctx context.Context, t *Task, name string, worker *ag
a.NodeID, a.Worker = &nid, name
emit(a)
}
workCtx = intercept.WithTaskContext(workCtx, t.ID, fmt.Sprintf("%s · #%d", name, iid), taskEmit)
hooks := steerHooks{inner: t.Guard.Hooks(), drain: func() (string, bool) { return e.drainSteer(iid) }}
label := fmt.Sprintf("%s · #%d", name, iid)
workCtx = intercept.WithTaskContext(workCtx, t.ID, label, taskEmit)
// nudges 有意建在 model_error 重跑循环之外:空转续跑的上限是「这条意图」的总量,
// 重跑一轮不该把额度清零重来。
hooks := steerHooks{
inner: t.Guard.Hooks(),
drain: func() (string, bool) { return e.drainSteer(iid) },
nudges: &atomic.Int64{},
limit: e.emptyTurnNudgeLimit(),
label: label,
}
wTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
e.BeginLLMCall(t.ID)
var reason harness.TerminalReason
+146
View File
@@ -0,0 +1,146 @@
package server
import (
"context"
"sync/atomic"
"testing"
"github.com/Autumn-27/norma/llm"
)
// 空转回合(仅思考、无正文无工具)的识别与续跑,见 steerHooks.Stop。
func assistantThinking(text string) llm.Message {
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{
{Type: llm.BlockThinking, Thinking: text, Signature: "sig"},
}}
}
func TestIsThinkingOnlyTurn(t *testing.T) {
toolUse := llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{
{Type: llm.BlockThinking, Thinking: "先扫端口"},
{Type: llm.BlockToolUse, ID: "t1", Name: "run_nuclei"},
}}
cases := []struct {
name string
msgs []llm.Message
want bool
}{
{"仅思考", []llm.Message{llm.UserText("开始"), assistantThinking("想想")}, true},
{"思考+工具", []llm.Message{llm.UserText("开始"), toolUse}, false},
{"思考+正文", []llm.Message{assistantThinking("想想"), {
Role: llm.RoleAssistant,
Content: []llm.ContentBlock{{Type: llm.BlockThinking, Thinking: "x"}, llm.TextBlock("结论")},
}}, false},
{"正文只有空白字符", []llm.Message{{
Role: llm.RoleAssistant,
Content: []llm.ContentBlock{{Type: llm.BlockThinking, Thinking: "x"}, llm.TextBlock(" \n ")},
}}, true},
{"完全空的 assistant 回合", []llm.Message{{Role: llm.RoleAssistant}}, true},
// 工具结果是 user 角色,判定必须回溯到它前面那条 assistant,而不是就近误判。
{"最后一条是工具结果", []llm.Message{toolUse, {
Role: llm.RoleUser,
Content: []llm.ContentBlock{{Type: llm.BlockToolResult, ToolUseID: "t1"}},
}}, false},
{"没有 assistant 消息", []llm.Message{llm.UserText("开始")}, false},
{"空历史", nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isThinkingOnlyTurn(c.msgs); got != c.want {
t.Fatalf("isThinkingOnlyTurn = %v, want %v", got, c.want)
}
})
}
}
// fakeHooks 是一个可编程的 inner HookRunner,用来验证 steerHooks 对 inner 决定的尊重。
type fakeHooks struct {
prevent bool
blocking []string
msg string
}
func (f fakeHooks) PreToolUse(context.Context, string, []byte) (bool, string, []byte) {
return false, "", nil
}
func (f fakeHooks) PostToolUse(context.Context, string, []byte, []byte, bool) {}
func (f fakeHooks) Stop(context.Context, []llm.Message) (bool, []string, string) {
return f.prevent, f.blocking, f.msg
}
func TestSteerHooksStopNudgesEmptyTurn(t *testing.T) {
empty := []llm.Message{assistantThinking("我应该先枚举子域名")}
t.Run("空转回合注入续跑指令", func(t *testing.T) {
h := steerHooks{nudges: &atomic.Int64{}, limit: defaultEmptyTurnNudges, label: "worker-1 · #1"}
prevent, blocking, _ := h.Stop(context.Background(), empty)
if prevent {
t.Fatal("空转回合不应硬停")
}
if len(blocking) != 1 || blocking[0] != emptyTurnNudge {
t.Fatalf("blocking = %v, want [emptyTurnNudge]", blocking)
}
})
t.Run("有正文或工具时不介入", func(t *testing.T) {
h := steerHooks{nudges: &atomic.Int64{}, limit: defaultEmptyTurnNudges}
normal := []llm.Message{{
Role: llm.RoleAssistant,
Content: []llm.ContentBlock{llm.TextBlock("已完成扫描,未发现开放端口")},
}}
if _, blocking, _ := h.Stop(context.Background(), normal); blocking != nil {
t.Fatalf("正常收场被误判为空转: %v", blocking)
}
if n := h.nudges.Load(); n != 0 {
t.Fatalf("未介入时不应计数, got %d", n)
}
})
t.Run("达到上限后放行收场", func(t *testing.T) {
const limit = 5 // 用户把「空响应重试次数」配成 5
h := steerHooks{nudges: &atomic.Int64{}, limit: limit}
for i := 1; i <= limit; i++ {
if _, blocking, _ := h.Stop(context.Background(), empty); len(blocking) != 1 {
t.Fatalf("第 %d 次应仍在配额内, blocking = %v", i, blocking)
}
}
if _, blocking, _ := h.Stop(context.Background(), empty); blocking != nil {
t.Fatalf("超出上限仍在注入: %v", blocking)
}
})
// 「空响应重试次数」配 -1 = 关掉这层,emptyTurnNudgeLimit 解析成 0。
t.Run("配置关闭时不介入", func(t *testing.T) {
h := steerHooks{nudges: &atomic.Int64{}, limit: 0}
if _, blocking, _ := h.Stop(context.Background(), empty); blocking != nil {
t.Fatalf("已关闭仍在注入: %v", blocking)
}
})
t.Run("inner 决定硬停时不叠加", func(t *testing.T) {
h := steerHooks{inner: fakeHooks{prevent: true, msg: "guard 拒绝收场"}, nudges: &atomic.Int64{}, limit: defaultEmptyTurnNudges}
prevent, blocking, msg := h.Stop(context.Background(), empty)
if !prevent || msg != "guard 拒绝收场" || blocking != nil {
t.Fatalf("inner 的硬停被改写: prevent=%v blocking=%v msg=%q", prevent, blocking, msg)
}
if n := h.nudges.Load(); n != 0 {
t.Fatalf("让位给 inner 时不应消耗配额, got %d", n)
}
})
t.Run("inner 已要续跑时不叠加", func(t *testing.T) {
h := steerHooks{inner: fakeHooks{blocking: []string{"guard 的续跑理由"}}, nudges: &atomic.Int64{}, limit: defaultEmptyTurnNudges}
_, blocking, _ := h.Stop(context.Background(), empty)
if len(blocking) != 1 || blocking[0] != "guard 的续跑理由" {
t.Fatalf("inner 的续跑消息被改写: %v", blocking)
}
})
t.Run("未装计数器时行为不变", func(t *testing.T) {
h := steerHooks{limit: defaultEmptyTurnNudges} // 例如未来其他调用点忘了传 nudges
if _, blocking, _ := h.Stop(context.Background(), empty); blocking != nil {
t.Fatalf("无计数器时不应注入: %v", blocking)
}
})
}
+26
View File
@@ -78,3 +78,29 @@ func (e *Engine) modelErrorRetryPolicy() (retries int, backoff time.Duration) {
}
return retries, backoff
}
// emptyTurnNudgeLimit resolves how many empty-turn continuations one work may
// inject (see steerHooks.Stop). It deliberately reuses layer ②'s knob —— 「空响应
// 重试次数」:两者是同一件事的两种手段。SDK 那层管「一个内容块都没有」,手段是把
// 同一个请求原样重发;这里管「只有思考、既无正文也无工具」,手段是追加一条指令让
// 模型带着已有思考接着做(原样重发对这种由上下文形状决定的空转没有意义)。判空口径
// 不同是因为 SDK 以「有没有 yield 过事件」为准,而思考增量本身就是事件——但用户配
// 「空响应重试几次」时想表达的是「模型没产出实质内容就再来一次」,两层共用一个次数
// 才对得上这个心智。
//
// 读全局策略而不是某个 profile 的覆盖:一个 run 中途可能因故障转移换 profile,而这
// 是整条意图的总量闸,不该跟着换端点而变。语义与 SDK 的 emptyRetries() 同构:
// 0 = 默认 defaultEmptyTurnNudges;-1(负) = 关闭空转续跑;>0 = 用该值。
func (e *Engine) emptyTurnNudgeLimit() int {
if e == nil || e.m == nil || e.m.pg == nil {
return defaultEmptyTurnNudges
}
switch n := e.m.pg.LLMRetryPolicy().Empty.Attempts; {
case n == 0:
return defaultEmptyTurnNudges
case n < 0:
return 0
default:
return n
}
}