// 材料整理入口(附件即保证):带附件的回合由系统确定性启动一个「材料整理」 // 后台子任务——不再依赖模型自觉调用 delegate_task(实测 MiniMax 对入口提示 // 无视、问一句就收场,快慢双速从未真正启动)。规则(用户 2026-10-11 裁定): // - 无文字发送或材料总量达阈值 → 发送瞬间预启动(先建卡再跑快通道); // - 文字+小材料 → 模型先答;回合结束(runtime stop 钩子)仍未启动则兜底启动; // - 模型自己已委派后台任务(delegatedBackground)时尊重模型判断,不重复兜底; // - 同一批材料按内容键去重:进行中不重建,已完成不重跑(失败可重试)。 package runmgr import ( "crypto/sha256" "encoding/hex" "fmt" "log" "sort" "strings" "unicode/utf8" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) 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, queue: 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) }