From 43087a79f86998ee5749eb5f893d8801cc694a19 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 10 Oct 2026 06:40:00 +0000 Subject: [PATCH] Make background subagents observable as the slow lane of dual-speed tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: SR-B10 (RDM requirements/01 V2.1, ruling J-03) defines the task-level dual-speed pattern: a fast lane gives immediate feedback while deep work runs in background with queryable progress and results posted back to the inbox. The base system had the background spawn/notice machinery but nothing the user could see while it ran or collect afterwards, so heavy tasks just made the user wait on a synchronous turn. Evidence: go test ./... -count=1 green (32 pkgs); new runmgr tests cover the card lifecycle on success (progress card -> done/ok with result summary and artifact line, card.created/updated/status events) and on failure (done/danger with the error), plus the cards==nil no-op path. Impact: spawnBackground now registers an inbox progress card linked to the child run and rewrites it with the result on completion; the delegate_task description and base-method working prompt teach the fast-feedback-first pattern; a #background mock demo exercises the whole path end to end. Refs: RDM docs/requirements/01 UR-27/SR-B10; .tmp 03-V2 §任务快慢双速通用模式; Dev task feature/dual-speed-tasks. --- internal/prompts/library/base-method.md | 1 + internal/runmgr/background_card_test.go | 150 ++++++++++++++++++++++++ internal/runmgr/runmgr.go | 61 +++++++++- internal/runtime/llm.go | 2 +- internal/runtime/llm_direct.go | 2 +- internal/runtime/mock.go | 14 +++ 6 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 internal/runmgr/background_card_test.go diff --git a/internal/prompts/library/base-method.md b/internal/prompts/library/base-method.md index 9fe9358..6b5d74b 100644 --- a/internal/prompts/library/base-method.md +++ b/internal/prompts/library/base-method.md @@ -12,5 +12,6 @@ description: 把任务做完的方法论:计划、换路、查 API、坚持到 - **写脚本前先查 API**:调用不熟悉的库/模块前,先用 grep 查它的函数签名(`inspect.signature`/`dir`)或读 SKILL.md 的示例,确认参数形态后再写完整脚本。猜 API 是失败的最大来源。 - **编辑粒度**:修改文件时先想清楚要改哪几处,用 multi_edit 或大块 edit 一次改完,不要一行一个 edit_file 连发十几次;改完立即验证(build/运行),验证通过再继续下一处。 - **坚持到完成**:任务没做完不要停。收尾前检查最后一段:如果还是计划、承诺或"接下来我将",就先把承诺的事做掉。只有用户输入才能解决的阻塞才允许停下来问。 +- **重任务走快慢双速**:预计要跑几分钟的任务(整篇文档深度解析、多文件批量处理、成套材料整理),先用当前回复给用户即时反馈——材料的相关性判断、概要或执行计划——再用 delegate_task 的 background=true 把深度工作放到后台执行;后台进度与结果会进收件箱卡片,完成后系统自动汇报。简单任务仍同步做完,不要为快而快把小事也丢后台。 - **结论回到材料**:写进交付物的数字、百分比、日期、人名必须能在本轮材料(附件/工具输出/会议原文)里找到出处;找不到的改成不写,或明确标注"推测"。材料里的日期基准与你理解的不一致时,以材料为准或向用户确认。 - **终稿如实**:最后的回复先给结论,再列交付物与要点;没做完、没验证的部分如实说明,不用模糊措辞掩盖。 diff --git a/internal/runmgr/background_card_test.go b/internal/runmgr/background_card_test.go new file mode 100644 index 0000000..86306dd --- /dev/null +++ b/internal/runmgr/background_card_test.go @@ -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 }) +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 6268be4..46ce9b7 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -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() diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 4593858..a3291f3 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -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"]}`)}, diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 668855a..1ba3f70 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -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 } diff --git a/internal/runtime/mock.go b/internal/runtime/mock.go index c98632a..36ee285 100644 --- a/internal/runtime/mock.go +++ b/internal/runtime/mock.go @@ -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 指令)") -- 2.54.0