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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 一致)"},
|
||||
|
||||
Reference in New Issue
Block a user