diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a3372f..8fba79f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## [Unreleased] +### Agent + +#### 修复的问题 + +- 修复 work 因**空转回合**提前中断的问题:模型有时会用一整轮只输出思考,既不给正文也不调工具,此时 harness 看到的是一次自然结束(`end_turn` 且无 `tool_use`),直接以 `completed` + 空总结收场——一条还没做完的意图就断在半路,表现为「任务正常结束但没有任何文字总结」。五层 LLM 重试一层都够不着它:它不是错误,同 provider 安全窗口重试的入口是"流失败",熔断在 `err == nil` 时直接判成功,意图重跑只认 `model_error`;SDK 的空响应重试也够不着,因为那层以"有没有 yield 过事件"判空,而思考增量本身就是事件。现在 work 的 Stop 钩子会识别这种回合并注入一条续跑指令,让模型带着已经产出的思考继续执行下一步——刻意不选"原样重发",因为这类空转通常由提示词与上下文形状决定,是稳定行为而非随机抽风,重发只会让模型再想一遍。 +- 次数复用 LLM 页「重试与退避」里的**空响应重试次数**(两者都在回答"模型完成了却没产出实质内容",只是判据与手段不同),默认 2 次,填 `-1` 即关掉、退回原先"空转即收场"的行为。它是一条意图的**总量**上限而非连续次数:harness 自身已限死连续空转只推一次,只有真正发生过工具回合配额才刷新,所以这个数挡的是「工具 → 空转 → 推 → 工具 → 空转」这类循环把意图预算耗光。触发时日志记 `[work · #<意图>] 空转回合…注入续跑指令 (n/N)`。设计见 `docs/LLM重试设计.md` §1.1。 + ## [0.3.8] - 2026-09-09 ### LLM diff --git a/server/engine.go b/server/engine.go index 1b4ba10..eddd0e2 100644 --- a/server/engine.go +++ b/server/engine.go @@ -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 diff --git a/server/engine_emptyturn_test.go b/server/engine_emptyturn_test.go new file mode 100644 index 0000000..6d703a4 --- /dev/null +++ b/server/engine_emptyturn_test.go @@ -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) + } + }) +} diff --git a/server/llmretry.go b/server/llmretry.go index 53f74eb..21a1e7d 100644 --- a/server/llmretry.go +++ b/server/llmretry.go @@ -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 + } +}