diff --git a/internal/runtime/dual_speed_nudge_test.go b/internal/runtime/dual_speed_nudge_test.go new file mode 100644 index 0000000..6b16870 --- /dev/null +++ b/internal/runtime/dual_speed_nudge_test.go @@ -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") + } +} diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 5dabe90..be3a1a4 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -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 diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index 13870d0..a6ac2d3 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -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 { diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index 703acf2..ffe4868 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -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 { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 050a083..dcd38cd 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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 一致)"},