Make background subagents observable as the slow lane of dual-speed tasks #125

Merged
weichen merged 1 commits from feature/dual-speed-tasks into main 2026-10-10 06:52:50 +00:00
6 changed files with 227 additions and 3 deletions
+1
View File
@@ -12,5 +12,6 @@ description: 把任务做完的方法论:计划、换路、查 API、坚持到
- **写脚本前先查 API**:调用不熟悉的库/模块前,先用 grep 查它的函数签名(`inspect.signature`/`dir`)或读 SKILL.md 的示例,确认参数形态后再写完整脚本。猜 API 是失败的最大来源。
- **编辑粒度**:修改文件时先想清楚要改哪几处,用 multi_edit 或大块 edit 一次改完,不要一行一个 edit_file 连发十几次;改完立即验证(build/运行),验证通过再继续下一处。
- **坚持到完成**:任务没做完不要停。收尾前检查最后一段:如果还是计划、承诺或"接下来我将",就先把承诺的事做掉。只有用户输入才能解决的阻塞才允许停下来问。
- **重任务走快慢双速**:预计要跑几分钟的任务(整篇文档深度解析、多文件批量处理、成套材料整理),先用当前回复给用户即时反馈——材料的相关性判断、概要或执行计划——再用 delegate_task 的 background=true 把深度工作放到后台执行;后台进度与结果会进收件箱卡片,完成后系统自动汇报。简单任务仍同步做完,不要为快而快把小事也丢后台。
- **结论回到材料**:写进交付物的数字、百分比、日期、人名必须能在本轮材料(附件/工具输出/会议原文)里找到出处;找不到的改成不写,或明确标注"推测"。材料里的日期基准与你理解的不一致时,以材料为准或向用户确认。
- **终稿如实**:最后的回复先给结论,再列交付物与要点;没做完、没验证的部分如实说明,不用模糊措辞掩盖。
+150
View File
@@ -0,0 +1,150 @@
package runmgr
import (
"errors"
"strings"
"sync"
"testing"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/runtime"
core "agent-system-core"
)
// newCardSpawnEnv 在后台派发环境上接线收件箱卡片服务,并捕获发出的卡片事件
// (任务快慢双速:慢通道进度卡 → 完成回投的验证环境)。
func newCardSpawnEnv(t *testing.T, runner runtime.Runner) (*runCtx, *[]string) {
t.Helper()
c, _ := newSpawnEnv(t, runner)
var mu sync.Mutex
var events []string
c.mgr.cards = cardsvc.New(c.mgr.st, func(conversationID, runID string, typ core.StreamEventType, payload any) error {
mu.Lock()
defer mu.Unlock()
events = append(events, string(typ))
return nil
})
return c, &events
}
func cardEvents(list *[]string) []string {
var out []string
for _, e := range *list {
if strings.HasPrefix(e, "card.") {
out = append(out, e)
}
}
return out
}
func TestBackgroundCardLifecycleOnSuccess(t *testing.T) {
runner := &captureRunner{parts: []core.Part{
{Type: core.PartText, Text: "深度解析完成:三份材料均相关,要点已汇总。"},
{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "art_0123456789ab", Name: "解析摘要.md"}},
}}
c, events := newCardSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
if len(systemTexts(t, c)) != 1 {
return false
}
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy
})
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(cards) != 1 {
t.Fatalf("cards = %d, want 1", len(cards))
}
card := cards[0]
if card.Kind != core.CardKindProgress || card.Title != "深度解析员" {
t.Fatalf("card = %+v", card)
}
if card.Status != core.CardDone || card.Tone != "ok" {
t.Fatalf("status = %s tone = %s, want done/ok", card.Status, card.Tone)
}
var md string
for _, n := range card.Body {
if n.Type == core.UIMarkdown {
md = n.Markdown
}
}
if !strings.Contains(md, "深度解析完成") || !strings.Contains(md, "解析摘要.md") {
t.Fatalf("result body = %q", md)
}
if card.SourceRunID == "" {
t.Fatal("card not linked to child run")
}
got := cardEvents(events)
for _, want := range []string{"card.created", "card.updated", "card.status"} {
found := false
for _, e := range got {
if e == want {
found = true
}
}
if !found {
t.Fatalf("events %v missing %s", got, want)
}
}
}
func TestBackgroundCardLifecycleOnFailure(t *testing.T) {
runner := &gateRunner{err: errors.New("boom: 材料解析失败")}
c, _ := newCardSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
if len(systemTexts(t, c)) != 1 {
return false
}
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy
})
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(cards) != 1 {
t.Fatalf("cards = %d, want 1", len(cards))
}
card := cards[0]
if card.Status != core.CardDone || card.Tone != "danger" {
t.Fatalf("status = %s tone = %s, want done/danger", card.Status, card.Tone)
}
var md string
for _, n := range card.Body {
if n.Type == core.UIMarkdown {
md = n.Markdown
}
}
if !strings.Contains(md, "执行失败") {
t.Fatalf("failure body = %q", md)
}
}
// TestBackgroundCardSkippedWithoutInbox:收件箱未启用(cards==nil)时后台派发
// 不建卡也不报错——既有 background_test 环境即此形态,这里显式断言不回归。
func TestBackgroundCardSkippedWithoutInbox(t *testing.T) {
runner := &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}}
c, _ := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if c.mgr.cards != nil {
t.Fatal("env unexpectedly wired cards")
}
if _, err := c.SpawnBackground("agent.sub.general", "后台", "任务", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool { return len(systemTexts(t, c)) == 1 })
}
+60 -1
View File
@@ -1597,15 +1597,74 @@ func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint str
m.decBackground(parent.ConversationID)
return "", err
}
// 慢通道可观测(任务快慢双速,SR-B10):后台子代理进收件箱进度卡,
// 完成后把结果回投该卡;通知回合路径保持不变,卡片是用户侧回收入口。
bgCard := m.createBackgroundCard(parent, rc.run.ID, name, task)
go func() {
defer m.decBackground(parent.ConversationID)
payload, _ := m.finishChild(rc, runner, false)
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.deliverNotices(parent.ConversationID)
}()
return rc.run.ID, nil
}
// createBackgroundCard 在收件箱登记慢通道进度卡。收件箱未启用(cards==nil)
// 或建卡失败时返回零值卡,完成回投随之跳过,不影响通知投递。
func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string) core.ActivityCard {
if m.cards == nil {
return core.ActivityCard{}
}
title := strings.TrimSpace(name)
if title == "" {
title = "后台任务"
}
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{{"通道", "慢通道 · 后台执行"}, {"状态", "执行中,完成后自动回投结果"}}},
},
}, "")
if err != nil {
log.Printf("[runmgr] background card create: %v", err)
return core.ActivityCard{}
}
return card
}
// finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行),
// 失败=错误说明;状态统一置 done,tone 区分成败。
func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error) {
if m.cards == nil || card.ID == "" {
return
}
summary := strings.TrimSpace(payload.Summary)
tone := "ok"
if err != nil {
tone = "danger"
if summary == "" {
summary = err.Error()
}
summary = "执行失败:" + summary
}
if summary == "" {
summary = "(无结果摘要)"
}
card.Body = []core.UINode{
{Type: core.UIMarkdown, Markdown: summary},
{Type: core.UIKV, KV: [][2]string{{"通道", "慢通道 · 后台执行"}, {"状态", "已完成"}}},
}
if uerr := m.cards.Update(owner, card); uerr != nil {
log.Printf("[runmgr] background card update: %v", uerr)
}
if _, serr := m.cards.SetStatus(owner, card.ID, core.CardDone, tone, ""); serr != nil {
log.Printf("[runmgr] background card status: %v", serr)
}
}
func (m *Manager) decBackground(convID string) {
m.noticeMu.Lock()
defer m.noticeMu.Unlock()
+1 -1
View File
@@ -307,7 +307,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
decls = append(decls, agentllm.ToolDecl{Name: toolSquad, Description: l.toolDesc(prof.Owner, toolSquad, "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户"), Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)})
}
decls = append(decls,
agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要。模型和工具由子代理 profile 决定,调用方不能指定模型。background 为真时立即返回,子代理完成后系统会自动发起一轮汇报"), Parameters: json.RawMessage(`{"type":"object","properties":{"agent":{"type":"string","enum":["general","explore","research","plan","verify","writer"],"description":"子代理 profile,缺省 general"},"name":{"type":"string","description":"显示名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"},"background":{"type":"boolean","description":"为真时在后台运行,当前回合不等待结果"}},"required":["name","task"]}`)},
agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要。模型和工具由子代理 profile 决定,调用方不能指定模型。background 为真时立即返回——这是重任务的慢通道:先在当前回复里给用户即时反馈(相关性判断/概要/计划),深度工作放后台;系统会在收件箱建进度卡并在完成后回投结果、自动发起一轮汇报。预计要跑几分钟的任务(整篇文档深度解析、多文件批量处理)优先用此模式,不要让用户干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"agent":{"type":"string","enum":["general","explore","research","plan","verify","writer"],"description":"子代理 profile,缺省 general"},"name":{"type":"string","description":"显示名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"},"background":{"type":"boolean","description":"为真时在后台运行(慢通道),当前回合不等待结果;收件箱有进度卡,完成后自动汇报"}},"required":["name","task"]}`)},
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"]}`)},
+1 -1
View File
@@ -679,7 +679,7 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
if serr != nil {
out = "子代理失败:" + serr.Error()
} else {
out = fmt.Sprintf("已在后台启动 %s,完成后会自动通知", id)
out = fmt.Sprintf("已在后台启动 %s(慢通道):收件箱已建进度卡,完成后结果回投该卡并自动汇报;先把快通道的即时反馈写给用户,不要等待", id)
}
break
}
+14
View File
@@ -226,6 +226,20 @@ func (m *MockRunner) Execute(rc RunCtx) error {
}
parts = append(parts, res.Parts...)
case strings.Contains(user, "#background"):
// 慢通道演示(任务快慢双速,SR-B10):快通道先反馈,后台子代理
// 进度进收件箱卡片,完成后结果回投该卡并发起汇报回合。
addText("✅ 快通道:材料已接收,先给结论——这是 mock 演示任务。深度解析已放慢通道后台执行:进度看右栏收件箱卡片,完成后自动回投结果并汇报。")
if sp, ok := rc.(interface {
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
}); ok {
if _, err := sp.SpawnBackground("agent.sub.general", "深度解析员", "对上传材料逐页解析并生成要点摘要(mock 后台演示)", "演示任务"); err != nil {
addText("后台启动失败:" + err.Error())
}
} else {
addText("当前运行上下文不支持后台派发(演示不可用)")
}
case strings.Contains(user, "#error"):
return fmt.Errorf("mock: 演示失败路径(#error 指令)")