Merge authoritative main into the bump branch
ci / vet-and-test (pull_request) Successful in 40s

This commit is contained in:
2026-10-10 10:11:07 +00:00
7 changed files with 295 additions and 5 deletions
+14
View File
@@ -148,3 +148,17 @@ func TestBackgroundCardSkippedWithoutInbox(t *testing.T) {
}
waitUntil(t, func() bool { return len(systemTexts(t, c)) == 1 })
}
// TestPartsTextStripsInlineThink:MiniMax 行内 <think> 块不进子代理摘要/回投卡。
func TestPartsTextStripsInlineThink(t *testing.T) {
got := partsText([]core.Part{{Type: core.PartText, Text: "<think>先读文件</think>\n正文开始\n<think>再想</think>结论"}})
if strings.Contains(got, "<think>") || strings.Contains(got, "先读文件") || strings.Contains(got, "再想") {
t.Fatalf("think leaked: %q", got)
}
if !strings.Contains(got, "正文开始") || !strings.Contains(got, "结论") {
t.Fatalf("body lost: %q", got)
}
if got := partsText([]core.Part{{Type: core.PartText, Text: "半截 <think> 未闭合保守保留"}}); !strings.Contains(got, "半截") {
t.Fatalf("unclosed think should keep text: %q", got)
}
}
+22 -1
View File
@@ -1898,13 +1898,34 @@ func partsText(parts []core.Part) string {
var sb strings.Builder
for _, p := range parts {
if p.Type == core.PartText {
sb.WriteString(p.Text)
text := p.Text
// MiniMax 等模型把推理写成行内 <think> 块:子代理摘要/回投卡正文
// 只取 think 之外的正文(实测 <think> 前缀泄漏进收件箱卡片与通知)。
if body := stripInlineThink(text); strings.TrimSpace(body) != "" || !strings.Contains(text, "</think>") {
text = body
}
sb.WriteString(text)
sb.WriteString("\n")
}
}
return strings.TrimSpace(sb.String())
}
// stripInlineThink 去掉文本中的 <think>…</think> 段(完整闭合才剥,半截保守保留)。
func stripInlineThink(text string) string {
for {
start := strings.Index(text, "<think>")
if start < 0 {
return text
}
end := strings.Index(text[start:], "</think>")
if end < 0 {
return text
}
text = text[:start] + text[start+end+len("</think>"):]
}
}
func artifactSummary(parts []core.Part) string {
var bits []string
seen := map[string]bool{}
+180
View File
@@ -0,0 +1,180 @@
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")
}
}
// 首次被无视后按 45s 间隔升级重复(最多 3 次),第二次起为硬性措辞。
func TestDualSpeedNudgeEscalates(t *testing.T) {
turns := append(toolOnlyTurns(35), textDeltaTurn("完成。")) // 35×5s=175s → 75s 起、120s、165s 三档
rec := runDualSpeed(t, turns, nil)
marks, hard := 0, 0
for _, r := range rec.request {
marks += strings.Count(r, "用户已经等待")
hard += strings.Count(r, "硬性要求")
}
if marks < 1 {
t.Fatalf("escalation missing: requests=%d", len(rec.request))
}
if marks > 2 {
t.Fatalf("escalated %d times, want at most 2 repeats after the first", marks)
}
}
// 入口前置:带附件的任务第 0 回合注入快慢双速选项;无附件不注入;阈值关闭不注入。
func TestDualSpeedEntryHintWithAttachments(t *testing.T) {
withAtt := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, func(rc *fakeRunCtx) {
rc.attachments = []core.ParsedDocument{{Meta: core.DocMeta{Filename: "meeting.docx"}}}
})
if !strings.Contains(strings.Join(withAtt.request, "\n"), "快慢双速") {
t.Fatal("entry hint missing for an attachment-bearing task")
}
noAtt := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, nil)
if strings.Contains(strings.Join(noAtt.request, "\n"), "本任务附带材料") {
t.Fatal("entry hint fired without attachments")
}
disabled := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, func(rc *fakeRunCtx) {
rc.attachments = []core.ParsedDocument{{Meta: core.DocMeta{Filename: "meeting.docx"}}}
rc.runtimeExtra = map[string]any{"dualSpeedNudgeSeconds": float64(0)}
})
if strings.Contains(strings.Join(disabled.request, "\n"), "本任务附带材料") {
t.Fatal("entry hint fired although the nudge setting is disabled")
}
}
+19 -1
View File
@@ -66,6 +66,12 @@ type directRun struct {
servedModel string
switches []string
switchMu sync.Mutex
// 任务快慢双速(SR-B10 实路径):started 用注入时钟记 run 起点,
// nudged/replied/delegated 三个标志驱动「超时仍未反馈则提醒转双速」的一次性触发。
started time.Time
nudgedDualSpeedCount int
replied bool
delegatedBackground bool
}
type toolEvidence struct {
@@ -98,7 +104,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, inflight: map[string]*toolSlot{}}
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow, started: l.now(), inflight: map[string]*toolSlot{}}
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
st.memoryStore = ms.StoreMemory()
@@ -289,6 +295,13 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
Reminders: func(turn int) []string {
var out []string
out = append(out, st.reminders(turn)...)
// 快慢双速入口前置(SR-B10 实路径):带附件的任务在第 0 回合就把
// 双速选项摆给模型——模型对开场指令的遵循远好于中途提醒(实测
// MiniMax 无视中途 system-reminder,但对工作方法开场指令照办)。
// 只提示一次,不强制拆分,保留 UR-27 的智能体自判。
if turn == 0 && st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds) > 0 && len(rc.Attachments()) > 0 {
out = append(out, "本任务附带材料,可能属于重任务。若预计要产出正式文档或做深度处理,采用快慢双速:先在当前回复里给用户即时反馈(材料相关性判断、概要或执行计划),再用 delegate_task 的 background=true 把深度工作放到后台执行——收件箱会显示进度卡,完成后系统自动回投结果并汇报。简单任务按常规同步完成即可。")
}
// 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
// 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。
if sessStore != nil && !seedRun && turn == 0 {
@@ -499,6 +512,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++
}
}
@@ -732,6 +749,7 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo
if serr != nil {
out = "子代理失败:" + serr.Error()
} else {
st.delegatedBackground = true
out = fmt.Sprintf("已在后台启动 %s(慢通道):收件箱已建进度卡,完成后结果回投该卡并自动汇报;先把快通道的即时反馈写给用户,不要等待", id)
}
break
+9 -3
View File
@@ -42,9 +42,11 @@ 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 组设置(双速提醒阈值等测试用)
attachments []core.ParsedDocument // Attachments 返回值(双速入口前置提示测试用)
spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error)
}
@@ -56,7 +58,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 {
@@ -146,7 +152,7 @@ func (f *fakeRunCtx) StoreMemory() agentmemory.Store {
return fakeMemStore{entries: f.memEntries}
}
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil }
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return f.attachments }
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
func (f *fakeRunCtx) Subtask() string { return "" }
func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
+50
View File
@@ -2,6 +2,7 @@ package runtime
import (
"fmt"
"log"
"strings"
"time"
@@ -149,6 +150,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 +183,48 @@ func (st *directRun) reminders(turn int) []string {
return got
}
// dualSpeedNudgeSeconds:双速提醒的运行时长阈值(秒)。0 = 关闭。
const dualSpeedNudgeDefaultSeconds = 75
// 双速提醒升级:首次被无视后每 45 秒重复、最多 3 次,第二次起改硬性措辞。
// 实测 MiniMax 对单次 system-reminder 无动于衷(opus 一次即响应)——重复+升级是
// 不改内核契约的前提下最强的请求侧手段;仍无效的模型记录到任务卡,后续考虑
// 系统级强制拆分(cancel + 后台续跑)方案。
const (
dualSpeedRepeatEvery = 45 * time.Second
dualSpeedMaxNudges = 3
)
// dualSpeedNudge 返回双速提醒文案;不满足条件或已达重复上限返回空串。
// 条件用注入时钟(l.now)计量,测试可控。
func (st *directRun) dualSpeedNudge() string {
after := int(st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds))
if after <= 0 || st.replied || st.delegatedBackground {
return ""
}
if st.started.IsZero() {
return ""
}
elapsed := st.l.now().Sub(st.started)
if elapsed < time.Duration(after)*time.Second {
return ""
}
due := 1 + int((elapsed-time.Duration(after)*time.Second)/dualSpeedRepeatEvery)
if due > dualSpeedMaxNudges {
due = dualSpeedMaxNudges
}
if st.nudgedDualSpeedCount >= due {
return ""
}
st.nudgedDualSpeedCount = due
secs := int(elapsed.Round(time.Second).Seconds())
log.Printf("[runtime] dual-speed nudge #%d injected at %ds (replied=%v delegated=%v)", due, secs, st.replied, st.delegatedBackground)
if due == 1 {
return fmt.Sprintf("本次任务已连续运行约 %d 秒,用户还没有收到任何答复(任务快慢双速,SR-B10)。现在二选一:若剩余工作量大(预计还要一分钟以上),先把你的判断、概要或执行计划作为即时反馈回复给用户,同时用 delegate_task 的 background=true 把深度处理放到后台执行(收件箱有进度卡,完成后系统自动回投结果并汇报);若已接近完成,把这句话忽略,继续做完后在最终答复里给出完整结论。不要默默长时间工作。", secs)
}
return fmt.Sprintf("用户已经等待约 %d 秒(第 %d 次提醒,硬性要求):立即停止后续工具调用,把当前进展、结论或计划作为回复发给用户;剩余的深度处理用 delegate_task 的 background=true 放到后台执行,完成后系统会自动回投结果。不要再让用户在没有反馈的情况下等待。", secs, due)
}
// unfinishedItems 返回计划与待办的未完成项正文(状态过滤留在产品侧)。
func (st *directRun) unfinishedItems() (todos, plans []string) {
for _, item := range st.todos.Items {
+1
View File
@@ -98,6 +98,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 一致)"},