Nudge long-running turns onto the dual-speed path via the kernel reminder hook

Why: the dual-speed slow lane shipped as an observable capability plus soft
prompt guidance, but on the real (non-mock) path models routinely grind
through heavy document tasks synchronously for minutes with zero user-visible
output - the pattern only engages when the model happens to think of it.
Evidence: five new unit tests over an injected clock cover fire-after-threshold,
quiet-before-threshold, disabled-by-setting, and the replied/delegated guard
paths (reminders observed in the captured kernel requests); full go test
./... 32 pkgs green. Real-model run recorded in the Dev task card.
Impact: runDirect tracks run start, user-reply, and background-delegation
state; reminders() injects a one-shot dual-speed nudge (SR-B10) once elapsed
time crosses runtime.dualSpeedNudgeSeconds (default 75s, 0 disables) while
the user has neither a reply nor a background lane. New setting registered.
Refs: Dev task feature/dual-speed-real-path; SR-B10 (RDM requirements/01 V2.1).
This commit is contained in:
2026-10-10 09:11:18 +00:00
parent c6912b63e7
commit 3f2c7c7033
5 changed files with 187 additions and 3 deletions
+142
View File
@@ -0,0 +1,142 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// 双速提醒经内核 Reminders 钩子进入下一请求的消息(不是响应部件):
// 这里捕获每次模型请求的原文,检索提醒标记。
type dualSpeedRecorder struct {
clock *stepClock
turns [][]agentllm.Event
request []string
base agentllm.StreamFunc
}
// stepClock 每 tick 前进 5 秒;runDirect 起 0 时刻,模型每被调用一次 tick 一格,
// 20 次调用即 100 秒量级,足以压过默认 75 秒阈值。
type stepClock struct{ step int64 }
func (c *stepClock) now() time.Time { return time.Unix(0, c.step*int64(5*time.Second)) }
func (c *stepClock) tick() { c.step++ }
func (rec *dualSpeedRecorder) stream(base agentllm.StreamFunc) agentllm.StreamFunc {
return func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
rec.clock.tick()
for _, m := range msgs {
if b, err := json.Marshal(m); err == nil {
rec.request = append(rec.request, string(b))
}
}
return base(ctx, model, msgs, tools, opts)
}
}
func (rec *dualSpeedRecorder) nudged() bool {
for _, r := range rec.request {
if strings.Contains(r, "任务快慢双速") {
return true
}
}
return false
}
func runDualSpeed(t *testing.T, turns [][]agentllm.Event, setup func(rc *fakeRunCtx)) *dualSpeedRecorder {
t.Helper()
rec := &dualSpeedRecorder{clock: &stepClock{}, turns: turns}
r := &LLMRunner{Stream: rec.stream(scriptStream(turns, nil)), Now: rec.clock.now}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}
if setup != nil {
setup(rc)
}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
return rec
}
func toolOnlyTurns(n int) [][]agentllm.Event {
var turns [][]agentllm.Event
for i := 0; i < n; i++ {
// 每次调用用独立 ID(toolCall 帮手的 ID 固定,重复会撞内核的工具槽位)。
call := agentllm.ToolCall{ID: fmt.Sprintf("c-ds-%d", i), Name: "make_report", Arguments: "{}"}
turns = append(turns, textDeltaTurn("", call))
}
return turns
}
// 长时间纯工具轮、用户零答复 → 阈值后恰好注入一次双速提醒。
func TestDualSpeedNudgeFiresOnceAfterThreshold(t *testing.T) {
turns := append(toolOnlyTurns(20), textDeltaTurn("完成。"))
rec := runDualSpeed(t, turns, nil)
if !rec.nudged() {
t.Fatal("dual-speed nudge missing after threshold with no user reply")
}
count := 0
for _, r := range rec.request {
count += strings.Count(r, "任务快慢双速")
}
if count > 1 {
t.Fatalf("nudge injected %d times, want at most once per request window", count)
}
}
// 短任务(阈值内完成)不提醒。
func TestDualSpeedNudgeQuietBeforeThreshold(t *testing.T) {
turns := append(toolOnlyTurns(1), textDeltaTurn("完成。"))
rec := runDualSpeed(t, turns, nil)
if rec.nudged() {
t.Fatal("nudge fired before the threshold")
}
}
// 设置 runtime.dualSpeedNudgeSeconds=0 → 关闭,超时也不提醒。
func TestDualSpeedNudgeDisabledBySetting(t *testing.T) {
turns := append(toolOnlyTurns(20), textDeltaTurn("完成。"))
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
rc.runtimeExtra = map[string]any{"dualSpeedNudgeSeconds": float64(0)}
})
if rec.nudged() {
t.Fatal("nudge fired although disabled via runtime.dualSpeedNudgeSeconds=0")
}
}
// 模型已给出面向用户的答复(快通道成立)→ 即使后续事实核查续写拖过阈值也不提醒。
func TestDualSpeedNudgeSkippedWhenReplied(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
textDeltaTurn("完整总结:产出物与要点。"),
textDeltaTurn("已修正并重新登记。"),
}
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
rc.verify = true
rc.execArtifact = &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"}
rc.execArtifactContent = []byte("# 报告\n\n准确率 95%。")
})
if rec.nudged() {
t.Fatal("nudge fired although the model already gave its user reply")
}
}
// 已把深度工作放后台(慢通道成立)→ 不再提醒。
func TestDualSpeedNudgeSkippedWhenDelegated(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("", toolCall("delegate_task", `{"name":"深度解析","task":"逐页解析","background":true}`)),
}
turns = append(turns, toolOnlyTurns(19)...)
turns = append(turns, textDeltaTurn("后台进行中,先给结论。"))
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
rc.bgID = "run_bg_1"
})
if rec.nudged() {
t.Fatal("nudge fired although background delegation already happened")
}
}
+12 -1
View File
@@ -65,6 +65,12 @@ type directRun struct {
servedModel string
switches []string
switchMu sync.Mutex
// 任务快慢双速(SR-B10 实路径):started 用注入时钟记 run 起点,
// nudged/replied/delegated 三个标志驱动「超时仍未反馈则提醒转双速」的一次性触发。
started time.Time
nudgedDualSpeed bool
replied bool
delegatedBackground bool
}
type toolEvidence struct {
@@ -97,7 +103,7 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo
}
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int, refresh turnRefresh) error {
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow}
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow, started: l.now()}
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
st.memoryStore = ms.StoreMemory()
@@ -473,6 +479,10 @@ func (st *directRun) onEvent(ev nruntime.Event) {
if body != "" {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: body, Process: hasCalls})
st.turnBody = body
if !hasCalls {
// 面向用户的答复段落已出现:快通道成立,双速提醒不再需要。
st.replied = true
}
st.textIdx++
}
}
@@ -693,6 +703,7 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
if serr != nil {
out = "子代理失败:" + serr.Error()
} else {
st.delegatedBackground = true
out = fmt.Sprintf("已在后台启动 %s(慢通道):收件箱已建进度卡,完成后结果回投该卡并自动汇报;先把快通道的即时反馈写给用户,不要等待", id)
}
break
+7 -2
View File
@@ -42,9 +42,10 @@ type fakeRunCtx struct {
bgID string
bgErr error
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试)
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入断言用)
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
bgCalls int
runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用)
}
func (f *fakeRunCtx) Context() context.Context { return f.ctx }
@@ -55,7 +56,11 @@ func (f *fakeRunCtx) Settings() core.EffectiveSettings {
if f.cardflow {
eff["ui"] = map[string]any{"cardflow": true}
}
eff["runtime"] = map[string]any{"verify": f.verify, "composeFinal": f.compose}
runtimeMap := map[string]any{"verify": f.verify, "composeFinal": f.compose}
for k, v := range f.runtimeExtra {
runtimeMap[k] = v
}
eff["runtime"] = runtimeMap
return eff
}
func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
+25
View File
@@ -149,6 +149,13 @@ func (st *directRun) reminders(turn int) []string {
st.closedToolTurn = false
st.checklistTouched = false
}
// 任务快慢双速(SR-B10 实路径触发):run 已运行超过阈值、用户还没看到任何
// 答复、也没把深度工作放后台时,一次性提醒转双速——先把判断/计划写给用户,
// 重活 delegate 后台。已经回复或已委派则永不打扰;接近完成的任务可按文案
// 自行忽略。这是内核每回合的 Reminders 注入点(agent-native 循环原生钩子)。
if note := st.dualSpeedNudge(); note != "" {
return []string{note}
}
if st.toolTurnsSince < gates.ReminderToolGap {
return nil
}
@@ -175,6 +182,24 @@ func (st *directRun) reminders(turn int) []string {
return got
}
// dualSpeedNudgeSeconds:双速提醒的运行时长阈值(秒)。0 = 关闭。
const dualSpeedNudgeDefaultSeconds = 75
// dualSpeedNudge 返回双速提醒文案;不满足条件或已提醒过返回空串。
// 条件用注入时钟(l.now)计量,测试可控。
func (st *directRun) dualSpeedNudge() string {
after := int(st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds))
if after <= 0 || st.nudgedDualSpeed || st.replied || st.delegatedBackground {
return ""
}
if st.started.IsZero() || st.l.now().Sub(st.started) < time.Duration(after)*time.Second {
return ""
}
st.nudgedDualSpeed = true
elapsed := int(st.l.now().Sub(st.started).Round(time.Second).Seconds())
return fmt.Sprintf("本次任务已连续运行约 %d 秒,用户还没有收到任何答复(任务快慢双速,SR-B10)。现在二选一:若剩余工作量大(预计还要一分钟以上),先把你的判断、概要或执行计划作为即时反馈回复给用户,同时用 delegate_task 的 background=true 把深度处理放到后台执行(收件箱有进度卡,完成后系统自动回投结果并汇报);若已接近完成,把这句话忽略,继续做完后在最终答复里给出完整结论。不要默默长时间工作。", elapsed)
}
// unfinishedItems 返回计划与待办的未完成项正文(状态过滤留在产品侧)。
func (st *directRun) unfinishedItems() (todos, plans []string) {
for _, item := range st.todos.Items {
+1
View File
@@ -97,6 +97,7 @@ func Schema() []core.SettingDesc {
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
{Key: "runtime.factCheck", Group: "runtime", Label: "终轮事实核查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "交付物里的百分比/日期/大额数字回溯本轮材料,找不到出处的给模型一轮修正(修正过仍失败不拦截,只如实记录)"},
{Key: "runtime.dualSpeedNudgeSeconds", Group: "runtime", Label: "快慢双速提醒阈值(秒)", Type: core.SettingNumber, Default: 75, Effect: core.EffectNewRun, Description: "任务连续运行超过该秒数、用户还没收到任何答复、也没把深度工作放后台时,一次性提醒模型转快慢双速:先把判断/概要/计划回复给用户,重活用 delegate_task 的 background 放后台(SR-B10)。0 = 关闭"},
{Key: "runtime.verifierSameModel", Group: "runtime", Label: "允许同源验证器", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "轻量档未单独配置时,默认跳过与主模型同源的独立验证(硬事实与事实核查仍执行);显式开启才用主模型做验证"},
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},