Files
Agent-System-Backend/internal/runmgr/material.go
T
weichen b2f4f98d3c Bind background lifecycle and recovery to persistent tasks
Why: Queued work and completed results need stable identity, execution fencing, and honest recovery.

Evidence: SELF make evidence records four-task promotion, restart reconciliation, stale-executor fencing, and existing material/notice tests.

Impact: Uses namespaced existing execution leases and canonical atomic task updates; legacy results require trusted input bindings before reuse.

Refs: fast-slow-u2-u3 Tasks 2 and 4
2026-10-13 01:39:02 +00:00

282 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 材料整理入口(附件即保证):带附件的回合由系统确定性启动一个「材料整理」
// 后台子任务——不再依赖模型自觉调用 delegate_task(实测 MiniMax 对入口提示
// 无视、问一句就收场,快慢双速从未真正启动)。规则(用户 2026-10-11 裁定):
// - 无文字发送或材料总量达阈值 → 发送瞬间预启动(先建卡再跑快通道);
// - 文字+小材料 → 模型先答;回合结束(runtime stop 钩子)仍未启动则兜底启动;
// - 模型自己已委派后台任务(delegatedBackground)时尊重模型判断,不重复兜底;
// - 同一批材料按内容键去重:进行中不重建,已完成不重跑(失败可重试)。
package runmgr
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"sort"
"strings"
"time"
"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 identifies content and parsing, never timestamps or upload IDs.
func materialKey(docs []core.ParsedDocument) string {
parts := make([]string, 0, len(docs))
for _, d := range docs {
hash, parserRev := "", ""
if d.Meta.Origin != nil {
hash = d.Meta.Origin.ContentHash
parserRev = d.Meta.Origin.ParserRevision
}
// Full blocks retain images, table structure and coverage warnings.
raw, _ := json.Marshal(struct {
Filename, MIME, Parser, ParserRevision, OriginalHash string
Blocks []core.DocBlock
Warnings []string
}{d.Meta.Filename, d.Meta.MIME, d.Meta.Parser, parserRev, hash, d.Blocks, d.Warnings})
parts = append(parts, string(raw))
}
sort.Strings(parts)
raw, _ := json.Marshal(parts)
sum := sha256.Sum256(raw)
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
}
}
b.WriteString(materialSources(docs, perDoc).prompt())
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, rc.materialRevision()); 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, rc.materialRevision())
}
// 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 := c.materialRevision()
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
}
if card.Task == nil || card.Task.InputRevision != key || card.ProjectID != rc.run.ProjectID {
continue
}
t := card.Task
if t.ExecutionState == "completed" {
if m.taskSourcesAvailable(rc.run.Owner, rc.run.ProjectID, t) {
return materialDoneNote, true
}
continue
}
if !t.Terminal() {
es, e := m.st.GetExecution(taskLeaseKey(t.TaskID))
if e == nil && es.InstanceID == t.OwnerInstanceID && es.Epoch == t.ExecutionEpoch && es.LeaseUntil > time.Now().UTC().Format(time.RFC3339Nano) {
return materialPendingNote, 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,
documents: rc.attachments,
sourceMessageID: rc.userMessage.ID,
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)
}