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