From 02858f486b54316c58d4f8ebcabfac4a02448ee1 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 16:45:49 +0000 Subject: [PATCH 1/3] Add deterministic material-intake background path for attachment sends Why: the dual-speed entry hint is advisory only; real runs (MiniMax, 3 attachments, no text) end with a clarifying question and zero delegation, so background cards and result backfill never start. Evidence: new unit tests in runmgr/material_test.go and runtime/material_flow_test.go all pass; full go test ./... exit 0 (local, SELF). Impact: attachment-carrying runs now guarantee a material-organization background task (pre-spawn on attachment-only/heavy sends, stop-hook backstop otherwise when the model did not delegate); subagent receives inline parsed text with budget plus knowledge_search guidance; content-key dedup prevents duplicate spawns (in-flight, completed, resend); model-authored delegate_task gains a materials appendix; settings runtime.materialIntake (default on) and runtime.materialIntakeChars added. Refs: Dev task .task/feature-material-intake-flow.md I1; user ruling 2026-10-11. --- internal/runmgr/material.go | 260 +++++++++++++++++++++++++ internal/runmgr/material_test.go | 180 +++++++++++++++++ internal/runmgr/runmgr.go | 92 +++++++-- internal/runtime/llm_direct.go | 45 ++++- internal/runtime/material_flow_test.go | 121 ++++++++++++ internal/runtime/sub_loop.go | 34 ++++ internal/runtime/verify.go | 5 + internal/settings/settings.go | 20 +- 8 files changed, 723 insertions(+), 34 deletions(-) create mode 100644 internal/runmgr/material.go create mode 100644 internal/runmgr/material_test.go create mode 100644 internal/runtime/material_flow_test.go diff --git a/internal/runmgr/material.go b/internal/runmgr/material.go new file mode 100644 index 0000000..93dbc90 --- /dev/null +++ b/internal/runmgr/material.go @@ -0,0 +1,260 @@ +// 材料整理入口(附件即保证):带附件的回合由系统确定性启动一个「材料整理」 +// 后台子任务——不再依赖模型自觉调用 delegate_task(实测 MiniMax 对入口提示 +// 无视、问一句就收场,快慢双速从未真正启动)。规则(用户 2026-10-11 裁定): +// - 无文字发送或材料总量达阈值 → 发送瞬间预启动(先建卡再跑快通道); +// - 文字+小材料 → 模型先答;回合结束(runtime stop 钩子)仍未启动则兜底启动; +// - 模型自己已委派后台任务(delegatedBackground)时尊重模型判断,不重复兜底; +// - 同一批材料按内容键去重:进行中不重建,已完成不重跑(失败可重试)。 +package runmgr + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "log" + "sort" + "strings" + "unicode/utf8" + + core "agent-system-core" +) + +const ( + materialSubagentID = "agent.sub.material" + materialCardTitle = "材料整理" + materialKVKey = "材料键" + materialCharsKey = "runtime.materialIntakeChars" + materialCharsDefaut = 12000 + // 单文档内联上限沿用父回合同款附件预算;三份上限内的典型材料可全文直达。 + materialPerDocKey = "context.maxAttachmentChars" + materialPerDocDef = 48000 + // 全部材料内联总预算(rune):超出部分截断,子代理用 knowledge_search 补齐 + // (上传时解析文本已进检索索引)。 + materialInlineTotal = 120000 +) + +const materialStartedNote = "系统已自动启动材料整理后台任务(收件箱进度卡「材料整理」已创建,完成后结果自动回投该卡并发起汇报)。你负责快通道:立即给用户即时反馈——每份材料一句话概览、初步判断、需要澄清的问题;不要等待整理完成,也不要用 delegate_task 重复派发这批材料的整理。整理结果回投后,再基于它推进正式交付物。" + +const materialPendingNote = "系统此前已为这批材料启动材料整理后台任务(见收件箱进度卡「材料整理」),完成后自动回投并汇报;不要重复派发整理任务,现在给用户即时反馈即可。" + +const materialDoneNote = "这批材料此前已整理完成(见收件箱「材料整理」卡片的结果)。不要重复派发整理任务;如需正式交付物(摘要/评审/需求文档/方案等),基于已有整理结果直接推进。" + +// materialKey 内容去重键:排序后的 文件名+解析字符数 参与哈希——同一批文件 +// 重发(新的消息、新的附件切片)得到同键;内容变化则视为新材料。 +func materialKey(docs []core.ParsedDocument) string { + parts := make([]string, 0, len(docs)) + for _, d := range docs { + parts = append(parts, fmt.Sprintf("%s|%d", d.Meta.Filename, utf8.RuneCountInString(d.PlainText()))) + } + sort.Strings(parts) + sum := sha256.Sum256([]byte(strings.Join(parts, "\n"))) + return hex.EncodeToString(sum[:]) +} + +func materialChars(docs []core.ParsedDocument) int { + total := 0 + for _, d := range docs { + total += utf8.RuneCountInString(d.PlainText()) + } + return total +} + +// messageText 提取消息的纯文本部分(判断「无文字发送」)。 +func messageText(m core.Message) string { + var b strings.Builder + for _, p := range m.Parts { + if p.Type == core.PartText { + b.WriteString(p.Text) + } + } + return strings.TrimSpace(b.String()) +} + +// buildMaterialTask 组装材料整理子任务正文:清单(文件名/字数/解析器/警告)+ +// 按预算内联的解析全文 + 整理指令(事实/结论/冲突/待办 + 用途建议,供交付物 +// 选择卡取材)。子代理只有 knowledge_search 一个材料通道(fs 工具是预留能力, +// 默认关),超预算部分靠检索补齐。 +func buildMaterialTask(eff core.EffectiveSettings, docs []core.ParsedDocument) string { + perDoc := int(eff.GetNumber(materialPerDocKey, materialPerDocDef)) + if perDoc <= 0 { + perDoc = materialPerDocDef + } + var b strings.Builder + fmt.Fprintf(&b, "整理用户上传的 %d 份材料并输出结构化摘要。\n\n", len(docs)) + b.WriteString("要求:\n") + b.WriteString("1. 逐文档输出:核心事实(数字/日期/决定事项逐条)、该文档的结论、与其他文档的冲突(如有)、待办或未决事项。\n") + b.WriteString("2. 整体输出:跨文档摘要(不超过 200 字),以及「建议后续处理」3-4 项(按材料实际内容,如会议摘要/评审报告/需求文档/架构方案),每项附一句依据——用户将从中选择正式交付物。\n") + b.WriteString("3. 所有引用标注来源文件名与章节;材料中找不到的信息不要编造。\n") + b.WriteString("4. 下面被截断的材料可用 knowledge_search 按文件名检索补齐。\n") + b.WriteString("\n=== 材料清单 ===\n") + left := materialInlineTotal + for _, d := range docs { + text := strings.TrimSpace(d.PlainText()) + runes := utf8.RuneCountInString(text) + fmt.Fprintf(&b, "\n### %s(%d 字,解析器 %s)\n", d.Meta.Filename, runes, d.Meta.Parser) + for _, w := range d.Warnings { + fmt.Fprintf(&b, "⚠ %s\n", w) + } + if runes == 0 { + b.WriteString("(无可解析文本)\n") + continue + } + cap := perDoc + if cap > left { + cap = left + } + if runes <= cap { + b.WriteString(text) + b.WriteString("\n") + left -= runes + continue + } + b.WriteString(string([]rune(text)[:cap])) + b.WriteString("\n…(超长截断,余下部分用 knowledge_search 检索本文件名)\n") + left -= cap + if left <= 0 { + left = 0 + } + } + return b.String() +} + +// materialIntake 预启动决策:附件即保证的「发送瞬间」分支。 +// 返回注入第 0 回合提示的系统说明;空串 = 本回合不预启动(留给 stop 兜底)。 +func (m *Manager) materialIntake(rc *runCtx) string { + if !rc.eff.GetBool("runtime.materialIntake", true) || len(rc.attachments) == 0 { + return "" + } + if note, existing := m.materialExisting(rc, materialKey(rc.attachments)); existing { + return note + } + userText := messageText(rc.userMessage) + threshold := int(rc.eff.GetNumber(materialCharsKey, materialCharsDefaut)) + if userText != "" && materialChars(rc.attachments) < threshold { + return "" // 文字+小材料:模型先答,回合结束兜底 + } + return m.materialSpawn(rc, materialKey(rc.attachments)) +} + +// ensureMaterial stop 兜底入口(幂等:只在首个调用实际派发并返回说明)。 +// runtime 侧在「模型未自己委派」时调用;是否真的启动由内容键去重决定。 +func (c *runCtx) ensureMaterial() string { + if c.materialEnsured { + return "" + } + c.materialEnsured = true + if len(c.attachments) == 0 || !c.eff.GetBool("runtime.materialIntake", true) { + return "" + } + key := materialKey(c.attachments) + if note, existing := c.mgr.materialExisting(c, key); existing { + c.materialNote = note + return note + } + if note := c.mgr.materialSpawn(c, key); note != "" { + c.materialNote = note + return note + } + return "" +} + +// MaterialNote 供 runtime 第 0 回合注入(预启动或去重命中时的既有说明)。 +func (c *runCtx) MaterialNote() string { return c.materialNote } + +// materialExisting 按内容键查既有任务:进行中 → 待回投说明;已成功完成 → +// 已完成说明;失败 → 允许重试(视为不存在)。先查内存注册表,再扫卡片 +// (重启后注册表丢失,卡片 KV 是持久凭据)。 +func (m *Manager) materialExisting(rc *runCtx, key string) (string, bool) { + convID := string(rc.run.ConversationID) + m.materialMu.Lock() + if m.materialActive != nil && m.materialActive[convID][key] { + m.materialMu.Unlock() + return materialPendingNote, true + } + m.materialMu.Unlock() + if m.cards == nil { + return "", false + } + cards, err := m.cards.List(rc.run.Owner, rc.run.ConversationID) + if err != nil { + return "", false + } + for _, card := range cards { + if card.Kind != core.CardKindProgress { + continue + } + // 卡片 KV 存材料键前缀(UI 友好),与全键做前缀匹配。 + if kv := materialKVOf(card); kv == "" || !strings.HasPrefix(key, kv) { + continue + } + switch card.Status { + case core.CardActive: + return materialPendingNote, true + case core.CardDone: + if card.Tone == "danger" { + continue // 失败可重试 + } + return materialDoneNote, true + } + } + return "", false +} + +func materialKVOf(card core.ActivityCard) string { + for _, node := range card.Body { + if node.Type != core.UIKV { + continue + } + for _, kv := range node.KV { + if kv[0] == materialKVKey { + return kv[1] + } + } + } + return "" +} + +// materialSpawn 启动材料整理后台任务(建卡 + 注册内容键)。 +// 派发失败(如容量满,I3 前为硬拒绝)返回空串,自然延后到下一次兜底机会。 +func (m *Manager) materialSpawn(rc *runCtx, key string) string { + short := key + if len(short) > 12 { + short = short[:12] + } + opts := bgSpawnOpts{ + agentID: materialSubagentID, + name: materialCardTitle, + task: buildMaterialTask(rc.eff, rc.attachments), + hint: "以材料实际内容为准;用途建议必须是材料能支撑的,不虚构。", + kv: [][2]string{{materialKVKey, short}, {"文件", fmt.Sprintf("%d 份", len(rc.attachments))}}, + taskKey: key, + material: true, + } + if _, err := m.spawnBackgroundOpts(rc.run, opts); err != nil { + log.Printf("[runmgr] material intake spawn: %v", err) + return "" + } + return materialStartedNote +} + +// materialKeyRegister/RegisterRelease 内容键注册表(内存档;卡片 KV 兜重启)。 +func (m *Manager) materialKeyRegister(convID, key string) { + m.materialMu.Lock() + defer m.materialMu.Unlock() + if m.materialActive == nil { + m.materialActive = map[string]map[string]bool{} + } + if m.materialActive[convID] == nil { + m.materialActive[convID] = map[string]bool{} + } + m.materialActive[convID][key] = true +} + +func (m *Manager) materialKeyRelease(convID, key string) { + m.materialMu.Lock() + defer m.materialMu.Unlock() + if m.materialActive == nil || m.materialActive[convID] == nil { + return + } + delete(m.materialActive[convID], key) +} diff --git a/internal/runmgr/material_test.go b/internal/runmgr/material_test.go new file mode 100644 index 0000000..be21e08 --- /dev/null +++ b/internal/runmgr/material_test.go @@ -0,0 +1,180 @@ +package runmgr + +import ( + "strings" + "testing" + + "agent-system-backend/internal/runtime" + core "agent-system-core" +) + +// holdRunner 阻塞到 release 才完成——材料整理去重需要子任务仍在运行的窗口。 +type holdRunner struct { + release chan struct{} + id string +} + +func (h *holdRunner) Name() string { return "hold" } + +func (h *holdRunner) Execute(rc runtime.RunCtx) error { + if src, ok := rc.(interface{ SubagentID() string }); ok { + h.id = src.SubagentID() + } + <-h.release + return rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "整理完成:三份材料的要点与冲突已汇总。"}}) +} + +func materialDocs() []core.ParsedDocument { + return []core.ParsedDocument{ + {Meta: core.DocMeta{Filename: "会议纪要.docx", Parser: "docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定上线新模块,预算 50 万,2026-11 前完成。"}}}, + {Meta: core.DocMeta{Filename: "评审意见.pdf", Parser: "pdf"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "对预算规模存疑,建议分两期。"}}}, + {Meta: core.DocMeta{Filename: "需求清单.md", Parser: "md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "共 12 项需求,3 项依赖外部接口。"}}}, + } +} + +func secondRunCtx(c *runCtx, docs []core.ParsedDocument, userText string) *runCtx { + msg := core.Message{ConversationID: c.run.ConversationID} + if userText != "" { + msg.Parts = []core.Part{{Type: core.PartText, Text: userText}} + } + return &runCtx{ + mgr: c.mgr, run: core.Run{ID: "run_second", ConversationID: c.run.ConversationID, ProjectID: c.run.ProjectID, Owner: c.run.Owner}, + eff: c.eff, userMessage: msg, attachments: docs, + } +} + +// 验收主用例的后端形态:三附件、无文字、直接发送 → 立即预启动、建卡、 +// 进行中去重、完成后按内容键不重跑。 +func TestMaterialIntakeAttachmentOnlyLifecycle(t *testing.T) { + hold := &holdRunner{release: make(chan struct{})} + c, events := newCardSpawnEnv(t, hold) + c.mgr.runner = noopRunner{} + c.attachments = materialDocs() + + note := c.mgr.materialIntake(c) + if !strings.Contains(note, "系统已自动启动材料整理后台任务") { + t.Fatalf("pre-spawn note = %q", note) + } + waitUntil(t, func() bool { + cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID) + return err == nil && len(cards) == 1 && cards[0].Title == materialCardTitle && cards[0].Status == core.CardActive + }) + if got := cardEvents(events); len(got) == 0 { + t.Fatal("card events missing on spawn") + } + + // 同批材料重复发送:进行中 → 待回投说明,不重建子任务。 + c2 := secondRunCtx(c, materialDocs(), "") + note2 := c.mgr.materialIntake(c2) + if !strings.Contains(note2, "此前已为这批材料启动") { + t.Fatalf("dedup note = %q", note2) + } + ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID) + if err != nil { + t.Fatal(err) + } + if len(ids) != 1 { + t.Fatalf("subagent runs = %d, want 1 (no respawn on resend)", len(ids)) + } + + close(hold.release) + waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 }) + + // 完成后同批再发:已完成说明,同样不重跑;卡片回投保留材料键。 + c3 := secondRunCtx(c, materialDocs(), "") + note3 := c.mgr.materialIntake(c3) + if !strings.Contains(note3, "已整理完成") { + t.Fatalf("done-dedup note = %q", note3) + } + ids, err = c.mgr.st.SubagentRunIDs(c.run.ConversationID) + if err != nil { + t.Fatal(err) + } + if len(ids) != 1 { + t.Fatalf("subagent runs after completion = %d, want 1", len(ids)) + } + cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID) + if err != nil || len(cards) != 1 { + t.Fatalf("cards = %v err = %v", cards, err) + } + if materialKVOf(cards[0]) == "" { + t.Fatal("material key KV lost on finish backfill") + } + if hold.id != materialSubagentID { + t.Fatalf("material subagent id = %q, want %q", hold.id, materialSubagentID) + } +} + +// 文字+小材料:预启动跳过(模型先答),ensureMaterial 兜底启动且幂等。 +func TestMaterialIntakeDeferredThenEnsure(t *testing.T) { + hold := &holdRunner{release: make(chan struct{})} + c, _ := newCardSpawnEnv(t, hold) + small := []core.ParsedDocument{ + {Meta: core.DocMeta{Filename: "说明.txt", Parser: "txt"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "短说明"}}}, + } + c.attachments = small + c.userMessage = core.Message{Parts: []core.Part{{Type: core.PartText, Text: "这份文件第三行说的对吗?"}}} + + if note := c.mgr.materialIntake(c); note != "" { + t.Fatalf("small text+attachment should defer, note = %q", note) + } + if got := c.ensureMaterial(); !strings.Contains(got, "系统已自动启动材料整理后台任务") { + t.Fatalf("ensure note = %q", got) + } + if again := c.ensureMaterial(); again != "" { + t.Fatalf("ensureMaterial not idempotent: %q", again) + } + close(hold.release) + waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 }) +} + +// runtime.materialIntake=false:入口与兜底都关闭(逃生开关)。 +func TestMaterialIntakeDisabled(t *testing.T) { + c, _ := newCardSpawnEnv(t, &captureRunner{}) + c.eff = core.EffectiveSettings{"runtime": map[string]any{"materialIntake": false}} + c.attachments = materialDocs() + if note := c.mgr.materialIntake(c); note != "" { + t.Fatalf("disabled intake note = %q", note) + } + if got := c.ensureMaterial(); got != "" { + t.Fatalf("disabled ensure note = %q", got) + } +} + +// 任务正文必须内联材料清单与解析文本;超预算截断并给出检索指引。 +func TestBuildMaterialTaskInlineBudget(t *testing.T) { + long := strings.Repeat("字", 600) + eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}} + task := buildMaterialTask(eff, []core.ParsedDocument{ + {Meta: core.DocMeta{Filename: "长文.docx", Parser: "docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}}, + }) + if !strings.Contains(task, "### 长文.docx") { + t.Fatal("manifest entry missing") + } + if !strings.Contains(task, "超长截断") || !strings.Contains(task, "knowledge_search") { + t.Fatal("truncation marker / retrieval hint missing") + } + full := buildMaterialTask(core.EffectiveSettings{}, materialDocs()) + for _, name := range []string{"会议纪要.docx", "评审意见.pdf", "需求清单.md"} { + if !strings.Contains(full, name) { + t.Fatalf("manifest missing %s", name) + } + } + if !strings.Contains(full, "决定上线新模块") { + t.Fatal("parsed text not inlined") + } +} + +func TestMaterialKeyStableAcrossOrder(t *testing.T) { + docs := materialDocs() + a := materialKey(docs) + reversed := []core.ParsedDocument{docs[2], docs[1], docs[0]} + if b := materialKey(reversed); a != b { + t.Fatal("key should be order-insensitive") + } + changed := materialDocs() + changed[0].Blocks[0].Text = "内容变了" + if b := materialKey(changed); a == b { + t.Fatal("key should change with content") + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index e7dc1ce..d63d9f6 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -85,6 +85,11 @@ type Manager struct { bgInFlight map[string]int delivering map[string]bool + // 材料整理内容键注册表(附件即保证):会话 → 活跃材料键。卡片 KV 是 + // 重启后的持久兜底,这里只是热路径的快速去重。 + materialMu sync.Mutex + materialActive map[string]map[string]bool + allowMu sync.Mutex sessionAllow map[string]map[string]bool } @@ -93,10 +98,11 @@ const maxBackgroundChildren = 3 // bgNotice 是一条尚未投递给父会话的后台子代理结果。 type bgNotice struct { - id string // 持久化队列主键(重启重投凭据) - owner string - name string - payload core.AgentResultPayload + id string // 持久化队列主键(重启重投凭据) + owner string + name string + payload core.AgentResultPayload + material bool // 材料整理任务:通知回合要衔接交付物选择(I2 消费) } // ErrConversationBusy 表示会话已有在途 run(HTTP 层映射 409)。 @@ -259,9 +265,13 @@ type runCtx struct { subTask string subHint string subAgent string + subModel string // 系统派发覆盖("chat" = 子代理用对话同档;空 = 按 spec) launch *workflow.Launch files *filestate.Tracker permissionMode string + // 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。 + materialNote string + materialEnsured bool } // Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌; @@ -652,6 +662,9 @@ func (c *runCtx) SubagentID() string { return c.subAgent } func (c *runCtx) Subhint() string { return c.subHint } +// Submodel 系统派发的模型档覆盖(材料整理用对话同档;空 = 子代理 spec 默认)。 +func (c *runCtx) Submodel() string { return c.subModel } + func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error { p.PlanID = c.run.ConversationID if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil { @@ -1236,6 +1249,11 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments) inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths) rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)} + // 材料整理(附件即保证):无文字/大材料发送瞬间预启动,说明注入第 0 回合; + // 其余带附件回合由 runtime stop 钩子兜底(模型已自行委派时尊重模型判断)。 + if launch == nil { + rc.materialNote = m.materialIntake(rc) + } go func() { // 执行许可(U3,R8):排队有界;获取失败(队列满/取消/存储故障)以 // failed 收口——不执行任何模型调用,副作用无从产生。 @@ -1634,6 +1652,12 @@ func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agen // startChild 做深度检查、建 run、发 spawn 事件。detach 时子 run 使用独立 ctx, // 父 run 结束不会取消它;cancel 仍登记在 active 里,CancelRun 和停机中断照常生效。 func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID, name, task, hint string, detach bool) (*runCtx, runtime.Runner, error) { + return m.startChildOpts(parentCtx, parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint}, detach) +} + +// startChildOpts 在 startChild 之上支持系统派发选项(模型档等)。 +func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o bgSpawnOpts, detach bool) (*runCtx, runtime.Runner, error) { + agentID, name, task, hint := o.agentID, o.name, o.task, o.hint if parent.Kind == core.RunSubagent { return nil, nil, fmt.Errorf("子代理不能再派发子代理") } @@ -1664,7 +1688,7 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID) eff := m.settings.Effective(parent.Owner, parent.ProjectID) - rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)} + rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, subModel: o.model, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)} m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing) runner := m.subRunner @@ -1750,10 +1774,33 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool return payload, nil } +// bgSpawnOpts 后台派发选项:模型 delegate_task 走零值;系统确定性派发 +// (材料整理)携带模型档、卡片 KV、内容键与材料标记。 +type bgSpawnOpts struct { + agentID, name, task, hint string + model string // "chat" = 子代理用对话同档模型(材料整理) + kv [][2]string + taskKey string // 内容键(非空 = 派发前去重、完成后保留键于卡片) + material bool // 材料整理任务(通知回合衔接交付物选择) +} + func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint string) (core.ID, error) { + return m.spawnBackgroundOpts(parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint}) +} + +func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, error) { if parent.Kind == core.RunSubagent { return "", fmt.Errorf("子代理不能再派发子代理") } + if o.taskKey != "" { + convID := string(parent.ConversationID) + m.materialMu.Lock() + active := m.materialActive != nil && m.materialActive[convID][o.taskKey] + m.materialMu.Unlock() + if active { + return "", fmt.Errorf("同键后台任务已在运行(材料键 %s),见收件箱进度卡", truncate(o.taskKey, 12)) + } + } m.noticeMu.Lock() if m.bgInFlight == nil { m.bgInFlight = map[string]int{} @@ -1765,29 +1812,36 @@ func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint str m.bgInFlight[parent.ConversationID]++ m.noticeMu.Unlock() - rc, runner, err := m.startChild(context.Background(), parent, agentID, name, task, hint, true) + rc, runner, err := m.startChildOpts(context.Background(), parent, o, true) if err != nil { m.decBackground(parent.ConversationID) return "", err } + if o.taskKey != "" { + m.materialKeyRegister(string(parent.ConversationID), o.taskKey) + } // 慢通道可观测(任务快慢双速,SR-B10):后台子代理进收件箱进度卡, // 完成后把结果回投该卡;通知回合路径保持不变,卡片是用户侧回收入口。 - bgCard := m.createBackgroundCard(parent, rc.run.ID, name, task) + bgCard := m.createBackgroundCard(parent, rc.run.ID, o.name, o.task, o.kv) + kv := o.kv go func() { defer m.decBackground(parent.ConversationID) + if o.taskKey != "" { + defer m.materialKeyRelease(string(parent.ConversationID), o.taskKey) + } // 后台子代理同样计入执行预算(U3:子代理预算计入全局)。 if err := m.adm.acquire(context.Background(), m.st, rc.run.ID, parent.Owner); err != nil { log.Printf("[runmgr] admission denied background %s: %v", rc.run.ID, err) m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase) m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}}) m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "后台子代理执行许可排队已满或已取消"}) - m.finishBackgroundCard(parent.Owner, bgCard, core.AgentResultPayload{}, err) + m.finishBackgroundCard(parent.Owner, bgCard, core.AgentResultPayload{}, err, kv) return } defer m.adm.release(m.st, rc.run.ID) payload, err := m.finishChild(rc, runner, false) - m.finishBackgroundCard(parent.Owner, bgCard, payload, err) - m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: name, payload: payload}) + m.finishBackgroundCard(parent.Owner, bgCard, payload, err, kv) + m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: o.name, payload: payload, material: o.material}) m.deliverNotices(parent.ConversationID) }() return rc.run.ID, nil @@ -1795,7 +1849,8 @@ func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint str // createBackgroundCard 在收件箱登记慢通道进度卡。收件箱未启用(cards==nil) // 或建卡失败时返回零值卡,完成回投随之跳过,不影响通知投递。 -func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string) core.ActivityCard { +// kv 为系统派发任务的附加键值(材料键等),随完成回投保留。 +func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string, kv [][2]string) core.ActivityCard { if m.cards == nil { return core.ActivityCard{} } @@ -1803,13 +1858,14 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t if title == "" { title = "后台任务" } + body := []core.UINode{ + {Type: core.UIMarkdown, Markdown: truncate(strings.TrimSpace(task), 400)}, + {Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", "执行中,完成后自动回投结果"})}, + } card, err := m.cards.Create(parent.Owner, core.ActivityCard{ Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info", ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID, - Body: []core.UINode{ - {Type: core.UIMarkdown, Markdown: truncate(strings.TrimSpace(task), 400)}, - {Type: core.UIKV, KV: [][2]string{{"通道", "慢通道 · 后台执行"}, {"状态", "执行中,完成后自动回投结果"}}}, - }, + Body: body, }, "") if err != nil { log.Printf("[runmgr] background card create: %v", err) @@ -1819,8 +1875,8 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t } // finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行), -// 失败=错误说明;状态统一置 done,tone 区分成败。 -func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error) { +// 失败=错误说明;状态统一置 done,tone 区分成败。kv 原样保留(材料键等持久凭据)。 +func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error, kv [][2]string) { if m.cards == nil || card.ID == "" { return } @@ -1838,7 +1894,7 @@ func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, pay } card.Body = []core.UINode{ {Type: core.UIMarkdown, Markdown: summary}, - {Type: core.UIKV, KV: [][2]string{{"通道", "慢通道 · 后台执行"}, {"状态", "已完成"}}}, + {Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", "已完成"})}, } if uerr := m.cards.Update(owner, card); uerr != nil { log.Printf("[runmgr] background card update: %v", uerr) diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 2c6ebe0..d7395bc 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -74,17 +74,18 @@ type directRun struct { // 从此有可指地址。 ledger []evidenceEntry ledgerCount int - progress *gates.ProgressReminder - checks []core.CheckPart - servedModel string - switches []string - switchMu sync.Mutex + progress *gates.ProgressReminder + checks []core.CheckPart + servedModel string + switches []string + switchMu sync.Mutex // 任务快慢双速(SR-B10 实路径):started 用注入时钟记 run 起点, // nudged/replied/delegated 三个标志驱动「超时仍未反馈则提醒转双速」的一次性触发。 started time.Time nudgedDualSpeedCount int replied bool delegatedBackground bool + materialChecked bool // 材料兜底只问一次(runCtx 侧另有幂等,双保险) } type toolEvidence struct { @@ -318,9 +319,18 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin // 快慢双速入口前置(SR-B10 实路径):带附件的任务在第 0 回合就把 // 双速选项摆给模型——模型对开场指令的遵循远好于中途提醒(实测 // MiniMax 无视中途 system-reminder,但对工作方法开场指令照办)。 - // 只提示一次,不强制拆分,保留 UR-27 的智能体自判。 + // 材料整理入口(附件即保证):系统已确定性启动时注入事实说明 + // (职责改为快通道反馈+后续交付物),未预启动(文字+小材料)时 + // 保留建议式提示,回合结束由 stop 钩子兜底保证。 if turn == 0 && st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds) > 0 && len(rc.Attachments()) > 0 { - out = append(out, "本任务附带材料,可能属于重任务。若预计要产出正式文档或做深度处理,采用快慢双速:先在当前回复里给用户即时反馈(材料相关性判断、概要或执行计划),再用 delegate_task 的 background=true 把深度工作放到后台执行——收件箱会显示进度卡,完成后系统自动回投结果并汇报。简单任务按常规同步完成即可。") + note := "" + if src, ok := rc.(interface{ MaterialNote() string }); ok { + note = strings.TrimSpace(src.MaterialNote()) + } + if note == "" { + note = "本任务附带材料,可能属于重任务。若预计要产出正式文档或做深度处理,采用快慢双速:先在当前回复里给用户即时反馈(材料相关性判断、概要或执行计划),再用 delegate_task 的 background=true 把深度工作放到后台执行——收件箱会显示进度卡,完成后系统自动回投结果并汇报。简单任务按常规同步完成即可。" + } + out = append(out, note) } // 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块) // 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。 @@ -578,6 +588,19 @@ func (st *directRun) onEvent(ev nruntime.Event) { } func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision { + // 材料整理兜底(附件即保证):带附件回合结束仍无后台任务且模型也没自行 + // 委派(delegatedBackground 尊重模型判断)→ 系统确定性启动,续跑一轮让 + // 模型告知用户。ensureMaterial 幂等 + materialChecked 一次性:不续跑循环。 + // 实测动机:MiniMax 对入口提示无视、问一句澄清就收场,0 委派 0 卡片, + // 用户全程无反馈。 + if !st.materialChecked && len(st.rc.Attachments()) > 0 && !st.delegatedBackground { + if ens, ok := st.rc.(interface{ EnsureMaterial() string }); ok { + st.materialChecked = true + if note := strings.TrimSpace(ens.EnsureMaterial()); note != "" { + return nruntime.StopDecision{Continue: true, Reason: materialBackstopReason} + } + } + } if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block { st.demoteLastText() if st.eff.GetBool("runtime.verify", false) { @@ -769,7 +792,13 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo if sp, ok := st.rc.(interface { SpawnBackground(agentID, name, task, hint string) (core.ID, error) }); ok { - id, serr := sp.SpawnBackground(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), a.Task, a.Hint) + // 材料传递(R2):模型自派的后台任务同样带上材料指引——子代理 + // 看不到父回合内联的附件全文,需指明检索通道。 + task := a.Task + if len(st.rc.Attachments()) > 0 { + task += delegateMaterialsNote(st.rc.Attachments()) + } + id, serr := sp.SpawnBackground(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), task, a.Hint) if serr != nil { out = "子代理失败:" + serr.Error() } else { diff --git a/internal/runtime/material_flow_test.go b/internal/runtime/material_flow_test.go new file mode 100644 index 0000000..88a6c59 --- /dev/null +++ b/internal/runtime/material_flow_test.go @@ -0,0 +1,121 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + agentllm "agent-native/runtime/llm" + core "agent-system-core" +) + +// matCtx 在 fakeRunCtx 之上实现材料整理的两个可选接口(附件即保证), +// 记录 EnsureMaterial 调用次数以断言 stop 兜底的幂等。 +type matCtx struct { + *fakeRunCtx + note string + ensureN int + lastNote string +} + +func (m *matCtx) MaterialNote() string { return m.note } + +func (m *matCtx) EnsureMaterial() string { + m.ensureN++ + if m.ensureN > 1 { + return "" + } + return m.lastNote +} + +func matAttachments() []core.ParsedDocument { + return []core.ParsedDocument{ + {Meta: core.DocMeta{Filename: "纪要.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定 11 月上线"}}}, + {Meta: core.DocMeta{Filename: "意见.pdf"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "预算存疑"}}}, + {Meta: core.DocMeta{Filename: "清单.md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "12 项需求"}}}, + } +} + +func runMaterial(t *testing.T, turns [][]agentllm.Event, rc *matCtx) *dualSpeedRecorder { + t.Helper() + rec := &dualSpeedRecorder{clock: &stepClock{}, turns: turns} + r := &LLMRunner{Stream: rec.stream(scriptStream(turns, nil)), Now: rec.clock.now} + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + return rec +} + +func requestsContaining(rec *dualSpeedRecorder, needle string) int { + n := 0 + for _, req := range rec.request { + if strings.Contains(req, needle) { + n++ + } + } + return n +} + +// 预启动说明替换建议式提示:第 0 回合注入事实说明而非「可能属于重任务」。 +func TestTurn0MaterialNoteReplacesAdvisory(t *testing.T) { + rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}, note: "系统已自动启动材料整理后台任务(收件箱进度卡「材料整理」已创建)。"} + rc.attachments = matAttachments() + rec := runMaterial(t, [][]agentllm.Event{textDeltaTurn("收到,先给每份材料一句话概览。")}, rc) + if requestsContaining(rec, "系统已自动启动材料整理后台任务") == 0 { + t.Fatal("material note not injected at turn 0") + } + if requestsContaining(rec, "可能属于重任务") != 0 { + t.Fatal("advisory text should be replaced when material note present") + } +} + +// 未预启动(文字+小材料):保留建议式入口提示。 +func TestTurn0AdvisoryWhenNoNote(t *testing.T) { + rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}} + rc.attachments = matAttachments() + rec := runMaterial(t, [][]agentllm.Event{textDeltaTurn("概览如下。")}, rc) + if requestsContaining(rec, "可能属于重任务") == 0 { + t.Fatal("advisory hint missing when no material note") + } +} + +// stop 兜底:模型问一句澄清就收场(用户实测现象)→ 系统续跑一轮要求告知; +// ensureMaterial 幂等保证只续跑一次。 +func TestStopMaterialBackstopContinuesOnce(t *testing.T) { + rc := &matCtx{ + fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}, + lastNote: "系统已自动启动材料整理后台任务", + } + rc.attachments = matAttachments() + turns := [][]agentllm.Event{ + textDeltaTurn("请问你需要哪种产出?"), + textDeltaTurn("材料正在后台整理,完成后自动汇报;先给概览。"), + } + rec := runMaterial(t, turns, rc) + if requestsContaining(rec, "系统已自动启动材料整理后台任务") == 0 { + t.Fatal("backstop reason not injected as continuation") + } + if rc.ensureN != 1 { + t.Fatalf("EnsureMaterial calls = %d, want exactly 1", rc.ensureN) + } +} + +// 模型已自行委派后台(delegatedBackground)→ 尊重模型判断,不触发兜底。 +func TestStopMaterialBackstopSkippedWhenDelegated(t *testing.T) { + rc := &matCtx{ + fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_1"}, + } + rc.attachments = matAttachments() + delegate := toolCall("delegate_task", `{"name":"深度解析","task":"解析材料","background":true}`) + turns := [][]agentllm.Event{ + textDeltaTurn("", delegate), + textDeltaTurn("已在后台解析,先给即时反馈。"), + } + rec := runMaterial(t, turns, rc) + if rc.ensureN != 0 { + t.Fatalf("EnsureMaterial calls = %d, want 0 (model delegated)", rc.ensureN) + } + if requestsContaining(rec, "自动启动材料整理后台任务") != 0 { + t.Fatal("backstop should not fire when model delegated") + } +} diff --git a/internal/runtime/sub_loop.go b/internal/runtime/sub_loop.go index 41f1a2d..3e5f302 100644 --- a/internal/runtime/sub_loop.go +++ b/internal/runtime/sub_loop.go @@ -19,6 +19,11 @@ const subagentFallback = "你是被派生的子代理。独立完成给定子任 const defaultSubagentID = "agent.sub.general" +// materialSubagentID 材料整理子代理(附件即保证):系统确定性派发,不依赖 +// 提示词库配置即可工作——spec 缺省在代码内(对话同档模型 + 检索;fs 工具是 +// 预留能力默认关,材料通道 = 任务正文内联 + knowledge_search)。 +const materialSubagentID = "agent.sub.material" + func fallbackSubagentSpec() prompts.SubagentSpec { return prompts.SubagentSpec{ Tools: []string{"knowledge_search", "web_search", "tavily_search", "time_now", "list_conversations", "read_conversation", "fs_read_file", "fs_list_dir"}, @@ -26,6 +31,13 @@ func fallbackSubagentSpec() prompts.SubagentSpec { } } +func materialSubagentSpec() prompts.SubagentSpec { + return prompts.SubagentSpec{ + Tools: []string{"knowledge_search", "time_now"}, + Model: "chat", + } +} + func (l *SubLLMRunner) Name() string { return "llm-subagent" } func (l *SubLLMRunner) Execute(rc RunCtx) error { @@ -36,11 +48,18 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error { } } spec := fallbackSubagentSpec() + if id == materialSubagentID { + spec = materialSubagentSpec() + } if l.Prompts != nil { if got, ok := l.Prompts.SubagentSpec(rc.Profile().Owner, id); ok { spec = got } } + // 系统派发的模型档覆盖(材料整理 = 对话同档):优先于 spec 默认。 + if src, ok := rc.(interface{ Submodel() string }); ok && src.Submodel() == "chat" { + spec.Model = "chat" + } maxTurns := spec.MaxTurns if maxTurns <= 0 { maxTurns = math.MaxInt32 @@ -253,6 +272,21 @@ func (st *subRun) runTool(name, args string) agenttools.Result { return agenttools.Result{Content: summary} } +// delegateMaterialsNote 模型自派后台任务的材料指引(R2):子代理看不到父回合 +// 内联的附件全文,指明 knowledge_search 检索通道(上传时解析文本已进索引)。 +func delegateMaterialsNote(docs []core.ParsedDocument) string { + names := make([]string, 0, len(docs)) + for _, d := range docs { + if n := strings.TrimSpace(d.Meta.Filename); n != "" { + names = append(names, n) + } + } + if len(names) == 0 { + return "" + } + return "\n\n[系统补充] 本会话材料文件:" + strings.Join(names, "、") + "。解析全文可用 knowledge_search 以文件名为关键词检索获取。" +} + func subagentProfileID(agent string) string { switch agent { case "explore", "research", "plan", "verify", "general", "writer": diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index 1d2baf0..a55a782 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -26,6 +26,11 @@ func systemCheckReason(body string) string { return "\n这是系统的终轮自动检查,不是用户发言。先用工具核实并修复指出的问题。检查带位置时先修那一处;确认检查本身有误时,在最终答复里用一句话说明原因(不要引用检查原文),然后如实收尾——既不要默默无视未通过的检查,也不要在没核实的情况下反复空转。\n" + body + "\n" } +// materialBackstopReason 材料整理兜底(附件即保证)的续跑指令:不走 +// systemCheckReason 的「核实修复」前缀——这里没有待修的检查,只有事实告知 +// 与职责提醒;ensureMaterial 幂等保证只续跑一轮。 +const materialBackstopReason = "\n这是系统的终轮自动处理,不是用户发言。系统已自动启动材料整理后台任务(收件箱有进度卡「材料整理」,完成后结果自动回投并汇报)。请在当前回复里告知用户这一点(一两句话即可),并补充你现在就能给的即时反馈——每份材料一句话概览或初步判断。不要用 delegate_task 重复派发材料整理,也不要等待整理完成再回复。\n" + func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) { route := ResolveLightweightModel(rc.Profile(), rc.Settings()) stream := l.VerifyStream diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 95c9b61..334ab89 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -34,14 +34,16 @@ func SystemDefaults() map[string]any { "tavily": map[string]any{"apiKeys": "", "endpoint": ""}, // 网络搜索(逗号分隔密钥池;空 = 不注册搜索工具) }, "runtime": map[string]any{ - "mock": false, // true = 全部会话走 mock runner(演示/测试) - "squad": true, // 小队编排(Agent-Squad 引擎)可用 - "entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认) - "rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限) - "redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内) - "verify": true, // 终轮确定性检查与独立验证 - "llmRetries": 2, // 直连模型在尚未输出时的额外重试次数 - "codeOrchestration": false, // 模型用脚本批量调用工具;默认关 + "mock": false, // true = 全部会话走 mock runner(演示/测试) + "squad": true, // 小队编排(Agent-Squad 引擎)可用 + "entry": "direct", // direct = 现有对话循环;squad = 小队分诊入口(灰度,达标后再改默认) + "rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限) + "redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内) + "verify": true, // 终轮确定性检查与独立验证 + "llmRetries": 2, // 直连模型在尚未输出时的额外重试次数 + "codeOrchestration": false, // 模型用脚本批量调用工具;默认关 + "materialIntake": true, // 附件即保证:带附件回合系统确定性启动材料整理后台任务 + "materialIntakeChars": 12000, // 带文字发送时材料总量达到该字符数也预启动(否则回合结束兜底) }, "storage": map[string]any{ "dataDir": "./data", // 启动注入实际值(--data) @@ -99,6 +101,8 @@ func Schema() []core.SettingDesc { {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.materialIntake", Group: "runtime", Label: "附件即材料整理", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "带附件的发送由系统确定性启动材料整理后台任务(无文字或大材料立即启动;文字+小材料回合结束兜底),进度卡即时入收件箱。关闭 = 回到模型自行判断"}, + {Key: "runtime.materialIntakeChars", Group: "runtime", Label: "材料预启动阈值(字符)", Type: core.SettingNumber, Default: 12000, Effect: core.EffectNewRun, Description: "带文字发送时,附件解析文本总量达到该字符数也立即预启动材料整理;未达到则模型先答、回合结束兜底"}, {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 一致)"}, From 42867aabf4d85b2ba36eca58850067d159ab14e2 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 16:53:53 +0000 Subject: [PATCH 2/3] Add deliverable-choice chain: confirm_choice tool, notice contract, backstops Why: after material organization completes, weak models report and end the turn without offering the user a choice of formal deliverables, and even after a user picks one the model may close the turn without starting the background task. Evidence: new tests (notice-choice chain, deliverable backstop, free-input path, invalid args, notice contract) pass; full go test ./... exit 0 (local, SELF). Impact: new confirm_choice tool issues a HITL choice card and routes the answer into a hard delegate instruction; material notices instruct the model to report then offer choices; stop hook adds two one-shot backstops (choice not offered in material notice runs; choice made but no background delegation). Refs: Dev task .task/feature-material-intake-flow.md I2. --- internal/runmgr/material_test.go | 29 +++++++ internal/runmgr/runmgr.go | 12 +++ internal/runtime/llm.go | 3 + internal/runtime/llm_direct.go | 91 +++++++++++++++++++++- internal/runtime/material_flow_test.go | 101 +++++++++++++++++++++++++ internal/runtime/verify.go | 11 +++ 6 files changed, 246 insertions(+), 1 deletion(-) diff --git a/internal/runmgr/material_test.go b/internal/runmgr/material_test.go index be21e08..b937637 100644 --- a/internal/runmgr/material_test.go +++ b/internal/runmgr/material_test.go @@ -178,3 +178,32 @@ func TestMaterialKeyStableAcrossOrder(t *testing.T) { t.Fatal("key should change with content") } } + +// 通知契约:材料整理完成的通知正文含交付物选择指令(confirm_choice); +// 普通后台任务的通知不含。 +func TestMaterialNoticeContract(t *testing.T) { + c, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "整理完成:要点已汇总。"}}}) + c.mgr.runner = noopRunner{} + c.attachments = materialDocs() + if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") { + t.Fatalf("intake note = %q", note) + } + waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 }) + joined := strings.Join(systemTexts(t, c), "\n") + if !strings.Contains(joined, "「材料整理」已结束") { + t.Fatalf("notice text = %q", joined) + } + if !strings.Contains(joined, "confirm_choice") { + t.Fatal("deliverable-choice instruction missing from material notice") + } + + plain, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}}) + plain.mgr.runner = noopRunner{} + if _, err := plain.SpawnBackground("agent.sub.general", "普通任务", "做点事", ""); err != nil { + t.Fatal(err) + } + waitUntil(t, func() bool { return len(systemTexts(t, plain)) >= 1 }) + if got := strings.Join(systemTexts(t, plain), "\n"); strings.Contains(got, "confirm_choice") { + t.Fatal("plain background notice must not carry the choice instruction") + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index d63d9f6..f037255 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -2044,6 +2044,18 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error { for _, n := range items { fmt.Fprintf(&b, "后台子代理「%s」已结束。\n状态:%s\n%s\n", n.name, n.payload.Status, strings.TrimSpace(n.payload.Summary)) } + // 材料整理完成 → 交付物选择职责(I2):汇报要点后立即发起待确认选择卡。 + // runtime 侧对不发 confirm_choice 的弱模型另有 stop 兜底(一次性)。 + materialSeen := false + for _, n := range items { + if n.material { + materialSeen = true + break + } + } + if materialSeen { + b.WriteString("\n这批材料的系统整理已完成。处理方式:\n1) 先用不超过 5 行汇报整理结果要点(来自上面的摘要)。\n2) 然后立即调用 confirm_choice 工具发起交付物选择卡:问题如「基于整理结果,接下来生成什么?」;选项取整理结果中的「建议后续处理」(3-4 项,description 写一句材料依据,最贴合的一项设 recommended),允许自由输入。\n3) 用户答复后按工具结果指引,用 delegate_task 的 background=true 启动对应后台任务。\n不要重复派发材料整理任务。\n") + } b.WriteString("") msg := core.Message{ ConversationID: convID, Role: core.RoleSystem, diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 4c7e069..d06dae1 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -67,6 +67,8 @@ const ( toolCardBegin = "card_begin" toolCardAppend = "card_append" toolCardCommit = "card_commit" + // confirm_choice 发起待确认选择卡(材料整理完成后选交付物;I2)。 + toolConfirmChoice = "confirm_choice" ) // resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目 @@ -312,6 +314,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { agentllm.ToolDecl{Name: toolReview, Description: l.toolDesc(prof.Owner, toolReview, "对一段材料做评审:多个审查子代理并行找问题,再逐条独立验证,只保留验证通过的问题"), Parameters: json.RawMessage(`{"type":"object","properties":{"target":{"type":"string","description":"要评审的文本或工件"},"focus":{"type":"string","description":"评审重点"},"reviewers":{"type":"integer","minimum":1,"maximum":3,"description":"并行审查人数,1 到 3,缺省 1"}},"required":["target"]}`)}, agentllm.ToolDecl{Name: toolPlan, Description: l.toolDesc(prof.Owner, toolPlan, "创建或更新当前会话的计划(步骤列表)"), Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done","skipped"]}},"required":["id","text","status"]}}},"required":["title","steps"]}`)}, agentllm.ToolDecl{Name: toolTodos, Description: l.toolDesc(prof.Owner, toolTodos, "更新当前会话的待办清单"), Parameters: json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done"]}},"required":["id","text","status"]}}},"required":["items"]}`)}, + agentllm.ToolDecl{Name: toolConfirmChoice, Description: l.toolDesc(prof.Owner, toolConfirmChoice, "向用户发起待确认选择卡并等待答复:3-4 个选项(各附一句依据与影响)+ 可选自由输入。材料整理完成后选正式交付物、或任何需要用户拍板的分叉都用它,不要在文本里罗列选项后干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"question":{"type":"string","description":"要用户决定的问题,一句话"},"options":{"type":"array","minItems":2,"maxItems":4,"items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string","description":"选项名,如:会议摘要"},"description":{"type":"string","description":"一句依据(来自材料或上下文)"},"impact":{"type":"string","description":"选择后的产出与耗时影响"},"recommended":{"type":"boolean","description":"最贴合的一项设 true"}},"required":["id","label","description","impact"]}},"free_input":{"type":"boolean","description":"允许自由输入其他要求,默认 true"},"timeout_sec":{"type":"integer","description":"等待上限秒数,0=不限"}},"required":["question","options"]}`)}, ) if eff.GetString("runtime.entry", "direct") != "squad" { decls = append(decls, agentllm.ToolDecl{ diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index d7395bc..aa40273 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -86,6 +86,15 @@ type directRun struct { replied bool delegatedBackground bool materialChecked bool // 材料兜底只问一次(runCtx 侧另有幂等,双保险) + // 交付物确认链(I2):confirm_choice 发起过选择卡;用户答复后 + // pendingDeliverable 非空——回合结束仍未委派则硬性续跑一轮。 + confirmIssued bool + pendingDeliverable string + deliverableNudged bool + // 材料整理通知回合(历史含「材料整理」完成标记):结束仍无选择卡且未委派 + // 时,硬性要求发起 confirm_choice(弱模型应急,一次性)。 + materialNotice bool + materialNoticeNudged bool } type toolEvidence struct { @@ -134,6 +143,24 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin st.pinSubagentArtifacts(text) } st.servedModel = model + // 材料整理通知回合识别(I2):历史含「材料整理」完成标记的系统提醒—— + // 该回合负有发起交付物选择卡的职责,stop 钩子据此兜底。扫会话历史而非 + // 组装后的 msgs:系统提示词块会撑大 msgs,窗口不稳定。 + histMsgs := rc.History() + for i := len(histMsgs) - 1; i >= 0 && i >= len(histMsgs)-8; i-- { + if histMsgs[i].Role != core.RoleSystem { + continue + } + for _, p := range histMsgs[i].Parts { + if p.Type == core.PartText && strings.Contains(p.Text, materialNoticeMarker) { + st.materialNotice = true + break + } + } + if st.materialNotice { + break + } + } stream = agentllm.WithFallback(stream, ChatFallbacks(eff), st.noteModelSwitch) defer func() { if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok { @@ -601,6 +628,18 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim } } } + // 交付物确认链(I2 混合兜底):用户已在选择卡拍板,但回合结束仍未委派 + // 后台任务 → 硬性续跑一轮要求启动(一次性)。 + if st.pendingDeliverable != "" && !st.delegatedBackground && !st.deliverableNudged { + st.deliverableNudged = true + return nruntime.StopDecision{Continue: true, Reason: deliverableBackstopReason} + } + // 材料通知回合应急(弱模型不发 confirm_choice):汇报了却没让用户选 + // 交付物、也没委派任何后台 → 硬性要求发起选择卡(一次性)。 + if st.materialNotice && !st.confirmIssued && !st.delegatedBackground && !st.materialNoticeNudged { + st.materialNoticeNudged = true + return nruntime.StopDecision{Continue: true, Reason: materialNoticeBackstopReason} + } if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block { st.demoteLastText() if st.eff.GetBool("runtime.verify", false) { @@ -826,6 +865,56 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo out = orDefault(res.Text, orDefault(res.Summary, "子代理完成")) st.pinSubagentArtifacts(res.Summary + "\n" + res.Text) } + case toolConfirmChoice: + var a struct { + Question string `json:"question"` + Options []struct { + ID, Label, Description, Impact string + Recommended bool + } `json:"options"` + FreeInput *bool `json:"free_input"` + TimeoutSec int `json:"timeout_sec"` + } + if uerr := json.Unmarshal([]byte(call.Arguments), &a); uerr != nil || strings.TrimSpace(a.Question) == "" || len(a.Options) < 2 { + out = "confirm_choice 参数无效:需要 question 与 2-4 个选项(id/label/description/impact)" + break + } + card := core.HITLCard{Kind: core.HITLKindChoice, Question: strings.TrimSpace(a.Question), AllowFreeInput: a.FreeInput == nil || *a.FreeInput} + for _, o := range a.Options { + choice := core.HITLChoice{ID: strings.TrimSpace(o.ID), Label: strings.TrimSpace(o.Label), Description: o.Description, Impact: o.Impact, Recommended: o.Recommended} + if choice.ID == "" { + choice.ID = choice.Label + } + if strings.TrimSpace(choice.Impact) == "" { + choice.Impact = "按选项说明执行" + } + card.Choices = append(card.Choices, choice) + } + if a.TimeoutSec > 0 { + secs := a.TimeoutSec + card.TimeoutSec = &secs + } + st.confirmIssued = true + resp, herr := st.rc.AskHITL(card) + if herr != nil { + out = "选择等待结束(未获得答复):" + herr.Error() + "。请在回复里列出可选项,请用户直接答复。" + break + } + if id := strings.TrimSpace(resp.ChoiceID); id != "" { + label := id + for _, c := range card.Choices { + if c.ID == id { + label = c.Label + } + } + st.pendingDeliverable = label + out = "用户已选择:" + label + "。立即用 delegate_task 的 background=true 启动对应后台任务(任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点),启动后给用户一句确认反馈即可,不要等待完成。" + } else if free := strings.TrimSpace(resp.FreeText); free != "" { + st.pendingDeliverable = free + out = "用户自由输入:" + free + "。若这是正式交付物要求,立即用 delegate_task 的 background=true 启动对应后台任务(描述结合材料整理结果);若是澄清或追问,先如实答复,再按需推进。" + } else { + out = "用户未给出明确答复。请在回复里列出可选项,请用户直接答复。" + } case toolReview: out = st.reviewFindings(call.Arguments) case toolPlan: @@ -1098,7 +1187,7 @@ func errString(err error) string { var serialTools = map[string]bool{ toolPlan: true, toolTodos: true, toolCard: true, toolCardBegin: true, toolCardAppend: true, toolCardCommit: true, - toolRunWorkflow: true, toolSquad: true, + toolRunWorkflow: true, toolSquad: true, toolConfirmChoice: true, "run_shell": true, "run_python": true, "register_artifact": true, "edit": true, "multi_edit": true, } diff --git a/internal/runtime/material_flow_test.go b/internal/runtime/material_flow_test.go index 88a6c59..0d03a9d 100644 --- a/internal/runtime/material_flow_test.go +++ b/internal/runtime/material_flow_test.go @@ -16,6 +16,10 @@ type matCtx struct { note string ensureN int lastNote string + // confirm_choice 断言(I2) + hitls []core.HITLCard + hitlFree string + hitlErr error } func (m *matCtx) MaterialNote() string { return m.note } @@ -28,6 +32,21 @@ func (m *matCtx) EnsureMaterial() string { return m.lastNote } +// AskHITL 覆盖:预置选择默认取第一个选项;hitlFree 模拟自由输入。 +func (m *matCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) { + m.hitls = append(m.hitls, card) + if m.hitlErr != nil { + return core.HITLResponse{}, m.hitlErr + } + if m.hitlFree != "" { + return core.HITLResponse{FreeText: m.hitlFree}, nil + } + if len(card.Choices) == 0 { + return core.HITLResponse{}, nil + } + return core.HITLResponse{ChoiceID: card.Choices[0].ID}, nil +} + func matAttachments() []core.ParsedDocument { return []core.ParsedDocument{ {Meta: core.DocMeta{Filename: "纪要.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定 11 月上线"}}}, @@ -119,3 +138,85 @@ func TestStopMaterialBackstopSkippedWhenDelegated(t *testing.T) { t.Fatal("backstop should not fire when model delegated") } } + +func hitlTurn(call agentllm.ToolCall) []agentllm.Event { return textDeltaTurn("", call) } + +// 完整确认链:材料通知回合 → 汇报但没发选择卡(应急兜底)→ 发起 confirm_choice +// → 用户选择 → 模型委派后台 → 通过(无进一步拦截)。 +func TestMaterialNoticeChoiceChain(t *testing.T) { + rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_d"}} + rc.history = []core.Message{{Role: core.RoleSystem, Parts: []core.Part{{Type: core.PartText, Text: "\n后台子代理「材料整理」已结束。\n状态:completed\n整理完成\n"}}}} + turns := [][]agentllm.Event{ + textDeltaTurn("整理完成:三份材料要点如下……"), // 未发选择卡 → 应急兜底 + hitlTurn(toolCall("confirm_choice", `{"question":"接下来生成什么?","options":[{"id":"a","label":"需求文档","description":"会议含明确需求项","impact":"产出需求文档"},{"id":"b","label":"会议摘要","description":"快速回顾","impact":"产出摘要"}]}`)), // AskHITL → 用户选第一个 + textDeltaTurn("好的,启动需求文档任务……", toolCall("delegate_task", `{"name":"生成需求文档","task":"基于整理结果生成需求文档","background":true}`)), + textDeltaTurn("已在后台启动需求文档,完成后自动汇报。"), + } + rec := runMaterial(t, turns, rc) + if requestsContaining(rec, "还没有让用户选择正式交付物") == 0 { + t.Fatal("notice backstop (confirm_choice nudge) missing") + } + if len(rc.hitls) != 1 || rc.hitls[0].Kind != core.HITLKindChoice { + t.Fatalf("hitl cards = %d", len(rc.hitls)) + } + if requestsContaining(rec, "用户已选择:需求文档") == 0 { + t.Fatal("choice answer not surfaced to model") + } + if requestsContaining(rec, "还没有启动") != 0 { + t.Fatal("deliverable backstop should not fire after model delegated") + } + if rc.ensureN != 0 { + t.Fatal("material backstop must not run in notice turns (no attachments)") + } +} + +// 用户已选定但模型未委派 → 硬性续跑一轮要求启动;第二轮委派后放行。 +func TestDeliverableBackstopForcesDelegation(t *testing.T) { + rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_e"}} + turns := [][]agentllm.Event{ + hitlTurn(toolCall("confirm_choice", `{"question":"生成什么?","options":[{"id":"a","label":"会议摘要","description":"快","impact":"摘要"},{"id":"b","label":"评审报告","description":"全","impact":"报告"}]}`)), + textDeltaTurn("好的,马上生成会议摘要。"), // 未委派 → 兜底 + textDeltaTurn("这就启动。", toolCall("delegate_task", `{"name":"生成会议摘要","task":"生成摘要","background":true}`)), + textDeltaTurn("已在后台生成会议摘要,稍后回投。"), + } + rec := runMaterial(t, turns, rc) + // 只触发一次由脚本回合数锁死:再续跑一次会多一次模型调用,脚本耗尽报错。 + if requestsContaining(rec, "还没有启动") == 0 { + t.Fatal("deliverable backstop missing") + } + if requestsContaining(rec, "用户已选择:会议摘要") == 0 { + t.Fatal("choice answer not surfaced to model") + } +} + +// 自由输入也应回到同一回合并进入待交付状态。 +func TestConfirmChoiceFreeTextSetsPending(t *testing.T) { + rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}, hitlFree: "做成对比表格"} + turns := [][]agentllm.Event{ + hitlTurn(toolCall("confirm_choice", `{"question":"生成什么?","options":[{"id":"a","label":"摘要","description":"快","impact":"摘要"},{"id":"b","label":"报告","description":"全","impact":"报告"}]}`)), + textDeltaTurn("收到,将输出对比表格。", toolCall("delegate_task", `{"name":"对比表格","task":"输出对比表格","background":true}`)), + textDeltaTurn("已在后台生成对比表格。"), + } + rec := runMaterial(t, turns, rc) + if requestsContaining(rec, "用户自由输入:做成对比表格") == 0 { + t.Fatal("free input not surfaced to model") + } + if requestsContaining(rec, "还没有启动") != 0 { + t.Fatal("no backstop expected when model delegated after free input") + } +} + +// 参数无效(选项不足)不建卡,直接给模型纠错说明。 +func TestConfirmChoiceRejectsInvalidArgs(t *testing.T) { + rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}} + turns := [][]agentllm.Event{ + hitlTurn(toolCall("confirm_choice", `{"question":"选什么?"}`)), + textDeltaTurn("改用两个选项重试。", toolCall("confirm_choice", `{"question":"选什么?","options":[{"id":"a","label":"摘要","description":"快","impact":"摘要"},{"id":"b","label":"报告","description":"全","impact":"报告"}]}`)), + textDeltaTurn("这就启动摘要。", toolCall("delegate_task", `{"name":"生成摘要","task":"生成摘要","background":true}`)), + textDeltaTurn("已在后台生成摘要。"), + } + runMaterial(t, turns, rc) + if len(rc.hitls) != 1 { + t.Fatalf("invalid args should not create card; hitls = %d", len(rc.hitls)) + } +} diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index a55a782..836db62 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -31,6 +31,17 @@ func systemCheckReason(body string) string { // 与职责提醒;ensureMaterial 幂等保证只续跑一轮。 const materialBackstopReason = "\n这是系统的终轮自动处理,不是用户发言。系统已自动启动材料整理后台任务(收件箱有进度卡「材料整理」,完成后结果自动回投并汇报)。请在当前回复里告知用户这一点(一两句话即可),并补充你现在就能给的即时反馈——每份材料一句话概览或初步判断。不要用 delegate_task 重复派发材料整理,也不要等待整理完成再回复。\n" +// materialNoticeMarker 材料整理完成通知的稳定标记:runmgr 的通知正文自然 +// 含此串;runtime 依此识别「本回合负有发起交付物选择卡的职责」。 +const materialNoticeMarker = "「材料整理」已结束" + +// deliverableBackstopReason 用户已选定交付物但后台任务未启动的硬性续跑指令。 +const deliverableBackstopReason = "\n这是系统的终轮自动检查,不是用户发言。用户已经在选择卡上做出决定,但对应的后台任务还没有启动。立即调用 delegate_task(background=true)启动它:任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点;启动后用一句话向用户确认即可,不要等待完成,也不要再次询问用户。\n" + +// materialNoticeBackstopReason 材料整理通知回合未发起选择卡的应急指令 +// (弱模型不发 confirm_choice 时兜底,一次性)。 +const materialNoticeBackstopReason = "\n这是系统的终轮自动检查,不是用户发言。材料整理已完成并汇报,但还没有让用户选择正式交付物。立即调用 confirm_choice 工具发起选择卡:问题「基于整理结果,接下来生成什么?」;选项从整理结果的「建议后续处理」取 3-4 项(description 写一句材料依据,最贴合的一项 recommended=true),允许自由输入;取不出建议时用默认选项:材料摘要、评审报告、需求文档、架构方案。发起后简短收尾即可。\n" + func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) { route := ResolveLightweightModel(rc.Profile(), rc.Settings()) stream := l.VerifyStream From 16ed7b281b1fed3359fadd6876c9fa16a2c96ecc Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 16:56:59 +0000 Subject: [PATCH 3/3] Add background queue for system tasks and flip ui.cardflow default on Why: at the 3-per-conversation cap the guaranteed material task was dropped silently, and long deliverables stayed in chat because cardflow defaulted off (user ruling 2026-10-11). Evidence: new queue lifecycle test (queued card, hard-reject for model path, FIFO start on release, card promote) passes; full go test ./... exit 0 (local, SELF). Impact: queueable system spawns create a queued progress card at capacity and start FIFO when a slot frees (memory-only queue; restart limitation noted); model delegate_task still hard-rejects; ui.cardflow default true (EffectNewRun). Refs: Dev task .task/feature-material-intake-flow.md I3. --- internal/runmgr/material.go | 1 + internal/runmgr/material_test.go | 75 ++++++++++++++++++++++ internal/runmgr/runmgr.go | 104 ++++++++++++++++++++++++++++--- internal/settings/settings.go | 4 +- 4 files changed, 173 insertions(+), 11 deletions(-) diff --git a/internal/runmgr/material.go b/internal/runmgr/material.go index 93dbc90..fc7d300 100644 --- a/internal/runmgr/material.go +++ b/internal/runmgr/material.go @@ -229,6 +229,7 @@ func (m *Manager) materialSpawn(rc *runCtx, key string) string { kv: [][2]string{{materialKVKey, short}, {"文件", fmt.Sprintf("%d 份", len(rc.attachments))}}, taskKey: key, material: true, + queue: true, // 容量满时排队显示而非放弃(附件即保证) } if _, err := m.spawnBackgroundOpts(rc.run, opts); err != nil { log.Printf("[runmgr] material intake spawn: %v", err) diff --git a/internal/runmgr/material_test.go b/internal/runmgr/material_test.go index b937637..ebb3ba1 100644 --- a/internal/runmgr/material_test.go +++ b/internal/runmgr/material_test.go @@ -1,6 +1,7 @@ package runmgr import ( + "fmt" "strings" "testing" @@ -207,3 +208,77 @@ func TestMaterialNoticeContract(t *testing.T) { t.Fatal("plain background notice must not carry the choice instruction") } } + +// 容量排队:满 3 时材料任务建排队卡不占位;槽位释放按 FIFO 启动并回填状态。 +func TestMaterialQueuedWhenFull(t *testing.T) { + c, _ := newCardSpawnEnv(t, nil) + c.mgr.runner = noopRunner{} + c.mgr.subRunner = &holdRunner{release: make(chan struct{})} // 占位用 + fills := make([]*holdRunner, 0, maxBackgroundChildren) + for i := 0; i < maxBackgroundChildren; i++ { + h := &holdRunner{release: make(chan struct{})} + fills = append(fills, h) + c.mgr.subRunner = h + if _, err := c.SpawnBackground("agent.sub.general", fmt.Sprintf("占位%d", i), "占位", ""); err != nil { + t.Fatal(err) + } + } + c.attachments = materialDocs() + if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") { + t.Fatalf("queued intake note = %q", note) + } + // 排队卡:active + 状态=排队中,且没有第 4 个子 run。 + waitUntil(t, func() bool { + cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID) + if err != nil { + return false + } + for _, card := range cards { + if card.Title == materialCardTitle && strings.Contains(cardBodyText(card), "排队中") { + return true + } + } + return false + }) + if ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID); err == nil && len(ids) != maxBackgroundChildren { + t.Fatalf("subagent runs = %d, want %d (queued task must not start yet)", len(ids), maxBackgroundChildren) + } + // 模型路径仍硬拒绝。 + if _, err := c.SpawnBackground("agent.sub.general", "第四个", "任务", ""); err == nil { + t.Fatal("model-path spawn should hard-reject at capacity") + } + // 释放一个槽位 → 排队任务启动(第 4 个子 run),卡片转执行态。 + close(fills[0].release) + waitUntil(t, func() bool { + ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID) + return err == nil && len(ids) == maxBackgroundChildren+1 + }) + waitUntil(t, func() bool { + cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID) + if err != nil { + return false + } + for _, card := range cards { + if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), "执行中") { + return true + } + } + return false + }) + for _, h := range fills[1:] { + close(h.release) + } +} + +func cardBodyText(card core.ActivityCard) string { + var b strings.Builder + for _, node := range card.Body { + if node.Type == core.UIMarkdown { + b.WriteString(node.Markdown) + } + for _, kv := range node.KV { + b.WriteString(kv[0] + kv[1]) + } + } + return b.String() +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index f037255..ce0b423 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -90,12 +90,23 @@ type Manager struct { materialMu sync.Mutex materialActive map[string]map[string]bool + // 后台排队(I3):会话满 3 时的系统派发任务先建卡(排队中)入队, + // 槽位释放按 FIFO 启动。模型 delegate_task 仍硬拒绝,不入队。 + bgQueue map[string][]bgQueueItem + allowMu sync.Mutex sessionAllow map[string]map[string]bool } const maxBackgroundChildren = 3 +// bgQueueItem 一个等待槽位的系统派发后台任务(建卡先行,子 run 启动延后)。 +type bgQueueItem struct { + parent core.Run + opts bgSpawnOpts + card core.ActivityCard +} + // bgNotice 是一条尚未投递给父会话的后台子代理结果。 type bgNotice struct { id string // 持久化队列主键(重启重投凭据) @@ -1775,13 +1786,14 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool } // bgSpawnOpts 后台派发选项:模型 delegate_task 走零值;系统确定性派发 -// (材料整理)携带模型档、卡片 KV、内容键与材料标记。 +// (材料整理)携带模型档、卡片 KV、内容键、材料标记与排队许可。 type bgSpawnOpts struct { agentID, name, task, hint string model string // "chat" = 子代理用对话同档模型(材料整理) kv [][2]string taskKey string // 内容键(非空 = 派发前去重、完成后保留键于卡片) material bool // 材料整理任务(通知回合衔接交付物选择) + queue bool // 满容量时可排队(系统任务;模型任务仍硬拒绝) } func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint string) (core.ID, error) { @@ -1801,17 +1813,40 @@ func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, return "", fmt.Errorf("同键后台任务已在运行(材料键 %s),见收件箱进度卡", truncate(o.taskKey, 12)) } } + conv := parent.ConversationID m.noticeMu.Lock() if m.bgInFlight == nil { m.bgInFlight = map[string]int{} } - if m.bgInFlight[parent.ConversationID] >= maxBackgroundChildren { + if m.bgInFlight[conv] >= maxBackgroundChildren { + if !o.queue { + m.noticeMu.Unlock() + return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren) + } + if m.bgQueue == nil { + m.bgQueue = map[string][]bgQueueItem{} + } + m.bgQueue[conv] = append(m.bgQueue[conv], bgQueueItem{parent: parent, opts: o}) m.noticeMu.Unlock() - return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren) + // 排队卡先行(用户立即可见排队态);SourceRunID 与执行态在槽位 + // 释放启动时回填。入队与建卡之间释放槽位的窗口由 pump 兜住: + // 下一次 decBackground 会尝试启动它。 + card := m.createBackgroundCard(parent, "", o.name, o.task, o.kv, fmt.Sprintf("排队中(本会话已有 %d 个后台任务,释放后自动开始)", maxBackgroundChildren)) + m.noticeMu.Lock() + if q := m.bgQueue[conv]; len(q) > 0 { + q[len(q)-1].card = card + } + m.noticeMu.Unlock() + return "", nil } - m.bgInFlight[parent.ConversationID]++ + m.bgInFlight[conv]++ m.noticeMu.Unlock() + return m.startBackgroundSlot(parent, o, core.ActivityCard{}) +} +// startBackgroundSlot 持槽启动后台任务(调用方已占 bgInFlight)。 +// preCard 非空时复用排队卡(回填 SourceRunID 并转执行态),否则新建执行卡。 +func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard core.ActivityCard) (core.ID, error) { rc, runner, err := m.startChildOpts(context.Background(), parent, o, true) if err != nil { m.decBackground(parent.ConversationID) @@ -1822,7 +1857,12 @@ func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, } // 慢通道可观测(任务快慢双速,SR-B10):后台子代理进收件箱进度卡, // 完成后把结果回投该卡;通知回合路径保持不变,卡片是用户侧回收入口。 - bgCard := m.createBackgroundCard(parent, rc.run.ID, o.name, o.task, o.kv) + bgCard := preCard + if bgCard.ID == "" { + bgCard = m.createBackgroundCard(parent, rc.run.ID, o.name, o.task, o.kv, "") + } else { + m.promoteQueuedCard(parent.Owner, preCard, rc.run.ID) + } kv := o.kv go func() { defer m.decBackground(parent.ConversationID) @@ -1847,10 +1887,29 @@ func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, return rc.run.ID, nil } +// pumpBackgroundQueue 槽位释放后按 FIFO 启动排队任务(decBackground 尾调)。 +func (m *Manager) pumpBackgroundQueue(conv string) { + m.noticeMu.Lock() + if len(m.bgQueue[conv]) == 0 || m.bgInFlight[conv] >= maxBackgroundChildren { + m.noticeMu.Unlock() + return + } + item := m.bgQueue[conv][0] + m.bgQueue[conv] = m.bgQueue[conv][1:] + m.bgInFlight[conv]++ + m.noticeMu.Unlock() + if _, err := m.startBackgroundSlot(item.parent, item.opts, item.card); err != nil { + log.Printf("[runmgr] background queue start: %v", err) + // 启动失败也要给排队卡一个终态,不能永久停在排队中。 + m.finishBackgroundCard(item.parent.Owner, item.card, core.AgentResultPayload{}, err, item.opts.kv) + } +} + // createBackgroundCard 在收件箱登记慢通道进度卡。收件箱未启用(cards==nil) // 或建卡失败时返回零值卡,完成回投随之跳过,不影响通知投递。 -// kv 为系统派发任务的附加键值(材料键等),随完成回投保留。 -func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string, kv [][2]string) core.ActivityCard { +// kv 为系统派发任务的附加键值(材料键等),随完成回投保留;status 非空时 +// 覆盖默认状态行(排队态文案)。 +func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string, kv [][2]string, status string) core.ActivityCard { if m.cards == nil { return core.ActivityCard{} } @@ -1858,9 +1917,12 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t if title == "" { title = "后台任务" } + if status == "" { + status = "执行中,完成后自动回投结果" + } body := []core.UINode{ {Type: core.UIMarkdown, Markdown: truncate(strings.TrimSpace(task), 400)}, - {Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", "执行中,完成后自动回投结果"})}, + {Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", status})}, } card, err := m.cards.Create(parent.Owner, core.ActivityCard{ Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info", @@ -1874,6 +1936,27 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t return card } +// promoteQueuedCard 排队任务获得槽位启动:回填 SourceRunID 并把状态行转为执行中。 +func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childID core.ID) { + if m.cards == nil || card.ID == "" { + return + } + card.SourceRunID = childID + for i, node := range card.Body { + if node.Type != core.UIKV { + continue + } + for j, kv := range node.KV { + if kv[0] == "状态" { + card.Body[i].KV[j][1] = "执行中,完成后自动回投结果" + } + } + } + if err := m.cards.Update(owner, card); err != nil { + log.Printf("[runmgr] queued card promote: %v", err) + } +} + // finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行), // 失败=错误说明;状态统一置 done,tone 区分成败。kv 原样保留(材料键等持久凭据)。 func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error, kv [][2]string) { @@ -1906,13 +1989,16 @@ func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, pay func (m *Manager) decBackground(convID string) { m.noticeMu.Lock() - defer m.noticeMu.Unlock() if m.bgInFlight == nil { + m.noticeMu.Unlock() return } if m.bgInFlight[convID] > 0 { m.bgInFlight[convID]-- } + m.noticeMu.Unlock() + // 槽位释放:按 FIFO 启动排队的系统任务(I3)。 + m.pumpBackgroundQueue(convID) } func (m *Manager) enqueueNotice(convID string, n bgNotice) { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 334ab89..3fcd55d 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -59,7 +59,7 @@ func SystemDefaults() map[string]any { "read": false, // 跨会话读取项目闸门(提案 2026-09-30-cross-session-reading):关 = 全项目禁读 }, "retrieval": map[string]any{"topK": 5}, - "ui": map[string]any{"theme": "dark", "cardflow": false}, + "ui": map[string]any{"theme": "dark", "cardflow": true}, // 长产物进卡片默认开(用户 2026-10-11 拍板) "deliverables": map[string]any{ "style": "modern", // modern / formal / minimal "accentColor": "", // 空 = 主题默认主色;#RRGGBB 或 RRGGBB @@ -118,7 +118,7 @@ func Schema() []core.SettingDesc { {Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"}, {Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot}, {Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot}, - {Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点"}, + {Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点(默认开,用户 2026-10-11 裁定)"}, {Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"}, {Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"}, {Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"},