fix(agent): 空转回合注入续跑指令,复用空响应重试次数
模型有时一整轮只输出思考、既不给正文也不调工具,harness 视为自然结束 (end_turn 且无 tool_use),直接以 completed + 空总结收场,一条未完成的意图 断在半路,表现为「任务正常结束但没有文字总结」。五层 LLM 重试均够不着它 (非错误路径),SDK 空响应重试也够不着(以是否 yield 过事件判空,而思考增量 本身就是事件)。 work 的 Stop 钩子现识别这种回合并注入一条续跑指令,让模型带着已有思考继续 执行下一步——不选原样重发,因这类空转由提示词与上下文形状决定,是稳定行为 而非随机抽风。次数复用 LLM 页「空响应重试次数」(默认 2、-1 关闭),是一条 意图的总量闸而非连续次数(harness 已限死连续空转只推一次)。
This commit is contained in:
1 parent
eb5290039a
commit
3c2a60fba7
4 files changed
+255
-4
No files matched your search
+76
-4
@@ -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
|
||||
|
||||
Reference in new issue
Block a user