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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+74
-18
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -26,6 +26,11 @@ func systemCheckReason(body string) string {
|
||||
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实并修复指出的问题。检查带位置时先修那一处;确认检查本身有误时,在最终答复里用一句话说明原因(不要引用检查原文),然后如实收尾——既不要默默无视未通过的检查,也不要在没核实的情况下反复空转。\n" + body + "\n</system-reminder>"
|
||||
}
|
||||
|
||||
// materialBackstopReason 材料整理兜底(附件即保证)的续跑指令:不走
|
||||
// systemCheckReason 的「核实修复」前缀——这里没有待修的检查,只有事实告知
|
||||
// 与职责提醒;ensureMaterial 幂等保证只续跑一轮。
|
||||
const materialBackstopReason = "<system-reminder>\n这是系统的终轮自动处理,不是用户发言。系统已自动启动材料整理后台任务(收件箱有进度卡「材料整理」,完成后结果自动回投并汇报)。请在当前回复里告知用户这一点(一两句话即可),并补充你现在就能给的即时反馈——每份材料一句话概览或初步判断。不要用 delegate_task 重复派发材料整理,也不要等待整理完成再回复。\n</system-reminder>"
|
||||
|
||||
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
|
||||
|
||||
@@ -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 一致)"},
|
||||
|
||||
Reference in New Issue
Block a user