Files
Agent-System-Backend/internal/runtime/mock.go
T
weichen 43087a79f8
ci / vet-and-test (pull_request) Successful in 45s
Make background subagents observable as the slow lane of dual-speed tasks
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.
2026-10-10 06:40:00 +00:00

312 lines
17 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.
package runtime
import (
"fmt"
"strings"
"time"
"agent-system-backend/internal/docgen"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/wfcompile"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
func firstNonEmpty0(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// MockRunner 按脚本回放事件流(P1 骨架闭环与验收基线;不调用任何模型)。
// 用户消息中的指令词触发不同演示路径,其余走默认脚本(思考→工具→回复)。
type MockRunner struct{}
func (m *MockRunner) Name() string { return "mock" }
func (m *MockRunner) Execute(rc RunCtx) error {
// 取最后一条用户消息文本
user := ""
if hist := rc.History(); len(hist) > 0 {
last := hist[len(hist)-1]
if last.Role == core.RoleUser {
for _, p := range last.Parts {
if p.Type == core.PartText {
user = p.Text
}
}
}
}
var parts []core.Part
addText := func(t string) {
parts = append(parts, core.Part{Type: core.PartText, Text: t})
}
// mock 路径同样产出上下文观测(演示可解释性入口;真实路径由 ctxasm 产出)。
rc.SetContextObservation(core.ContextObservation{
Items: []string{"system(画像)", "history(mock)", "attachments(0)"},
EstChars: len(user) + 128,
})
switch {
case strings.Contains(user, "#hitl"):
resp, err := rc.AskHITL(core.HITLCard{
Question: "演示:采用哪种输出格式?", Kind: core.HITLKindChoice,
Choices: []core.HITLChoice{
{ID: "md", Label: "Markdown 文档", Description: "可版本管理的轻量文档", Impact: "写入 artifacts,可在右栏查看与继续编辑", Recommended: true},
{ID: "docx", Label: "Word 文档", Description: "正式交付格式", Impact: "生成 docx 工件,后续修订走版本对比"},
{ID: "both", Label: "两种都要", Description: "同时产出", Impact: "生成两个工件"},
},
AllowFreeInput: true, FreeInputHint: "也可以直接输入你的要求(输入 / 开头则转新命令)",
})
if err != nil {
return err
}
answer := resp.FreeText
if resp.ChoiceID != "" {
answer = "选项 " + resp.ChoiceID
}
parts = append(parts, core.Part{Type: core.PartHITL, Hitl: &core.HITLSummary{Question: "演示:采用哪种输出格式?", Resolved: true, Answer: answer}})
addText(fmt.Sprintf("收到你的回答:%s。已按该选择继续执行。", answer))
case strings.Contains(user, "#agents"):
rc.UpdatePlan(core.PlanSnapshot{Title: "多代理演示计划", Steps: []core.PlanStep{
{ID: "s1", Text: "检索资料", Status: "in_progress"},
{ID: "s2", Text: "撰写摘要", Status: "pending"},
{ID: "s3", Text: "汇总输出", Status: "pending"},
}})
rc.UpdateTodos(core.TodoList{Items: []core.TodoItem{
{ID: "t1", Text: "派生两个子代理", Status: "in_progress"},
{ID: "t2", Text: "等待结果", Status: "pending"},
}})
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "任务需要并行检索,派生子代理……"})
parts = append(parts, core.Part{Type: core.PartThinking, Text: "任务需要并行检索,派生子代理……", Process: true})
r1, _ := rc.SpawnSubagent("资料检索员", "检索项目知识与历史会话", "知识检索")
r2, _ := rc.SpawnSubagent("撰稿员", "根据检索结果撰写摘要", "写作")
rc.UpdateTodos(core.TodoList{Items: []core.TodoItem{
{ID: "t1", Text: "派生两个子代理", Status: "done"},
{ID: "t2", Text: "等待结果", Status: "done"},
}})
rc.UpdatePlan(core.PlanSnapshot{Title: "多代理演示计划", Steps: []core.PlanStep{
{ID: "s1", Text: "检索资料", Status: "done"},
{ID: "s2", Text: "撰写摘要", Status: "done"},
{ID: "s3", Text: "汇总输出", Status: "in_progress"},
}})
addText(fmt.Sprintf("两个子代理已完成:%s(%s);%s(%s)。可在右栏 Agents 标签查看任务树与中间过程。",
r1.Summary, r1.Status, r2.Summary, r2.Status))
rc.UpdatePlan(core.PlanSnapshot{Title: "多代理演示计划", Steps: []core.PlanStep{
{ID: "s1", Text: "检索资料", Status: "done"},
{ID: "s2", Text: "撰写摘要", Status: "done"},
{ID: "s3", Text: "汇总输出", Status: "done"},
}})
case strings.Contains(user, "#upload"):
// 收件箱卡片流演示(提案 T2):摄取卡生命周期 + 冲突对比卡(待处理)。
addText("📎 已上传 临床前动物试验方案-草案.pdf(演示)——上传即构建记忆:先给相关性结论,解析进度在右栏卡片。")
card, _ := rc.CreateCard(core.ActivityCard{Kind: core.CardKindProgress, Title: "临床前动物试验方案-草案.pdf", Status: core.CardActive, Tone: "info",
Body: []core.UINode{{Type: core.UIProgress, Progress: &core.UIProgressSpec{Label: "解析中", Value: 0.25}}}})
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "记忆构建管道:相关性 ✓(命中:试验样本量、随访周期)→ 拆解 → 打标签 → 切片入库……"})
parts = append(parts, core.Part{Type: core.PartThinking, Text: "记忆构建管道:相关性 ✓ → 拆解 → 打标签 → 切片入库", Process: true})
card.Body = []core.UINode{
{Type: core.UIProgress, Progress: &core.UIProgressSpec{Label: "切片 24/24", Value: 1, Tone: "ok"}},
{Type: core.UIKV, KV: [][2]string{{"快通道", "✓ 38s"}, {"慢通道", "✓ 3m12s"}, {"追溯候选边", "6 条"}}},
{Type: core.UIList, Title: "标签", Items: []string{"验证确认(跨阶段引用)", "technical ×1", "regulatory ×1"}},
}
card.Status = core.CardDone
card.Tone = "ok"
_ = rc.SetCardStatus(card.ID, core.CardDone, "ok", "")
_ = rc.Emit(core.EvCardUpdated, core.CardPayload{Card: card})
cmp, _ := rc.CreateCard(core.ActivityCard{Kind: core.CardKindCompare, Title: "项目执行周期不一致 · 目标已基线", Status: core.CardActionRequired, Tone: "danger",
Body: []core.UINode{{Type: core.UICompare, Compare: &core.UICompareSpec{
Left: core.UICompareSide{Title: "基线 V1.2 · 已评审", Tone: "danger", Nodes: []core.UINode{{Type: core.UIMarkdown, Markdown: "12 个月(立项到注册)"}}},
Right: core.UICompareSide{Title: "新资料 · 待确认", Tone: "ok", Nodes: []core.UINode{{Type: core.UIMarkdown, Markdown: "18 个月(含临床试验)"}}},
}}},
Actions: []core.UIAction{{ID: "escalate", Label: "发起变更流程", Style: "primary"}, {ID: "keep", Label: "维持原样", Style: "danger"}},
})
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: "cardlink", Data: []core.UINode{{Type: core.UIMarkdown, Markdown: fmt.Sprintf("↗ 解析完成见卡片;冲突处理卡 `card:%s` 已进右栏收件箱(待处理)", cmp.ID)}}}})
case strings.Contains(user, "#cardflow"):
// 卡流机制演示(CF1/D39):产物卡显式路由——中栏结论摘要 + 引用锚点,全文进收件箱。
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "生成会议纪要……"})
parts = append(parts, core.Part{Type: core.PartThinking, Text: "生成会议纪要……", Process: true})
body := "# 会议纪要(演示)\n\n## 决议\n\n1. 注册路径走 FDA 510(k)\n2. 样机 B 版冻结\n3. 供应商切换延后一轮\n\n## 待办\n\n- 法规组输出对比表\n- 结构组更新 BOM\n"
card, _ := rc.SubmitCard(core.ActivityCard{
Kind: core.CardKindResult, Title: "会议纪要 · 第 12 次周会", Status: core.CardDone, Tone: "ok",
Body: []core.UINode{
{Type: core.UIMarkdown, Markdown: body},
{Type: core.UIKV, KV: [][2]string{{"摘要", "三项决议、两项待办;注册路径定为 510(k)"}}},
},
})
addText("已完成会议纪要:三项决议、两项待办,注册路径定为 510(k)。全文见右侧卡片,可在收件箱置顶或收纳。")
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
case strings.Contains(user, "#cardstream"):
// 流式产卡演示(CF-S/D44-D45):card_begin → append×N → card_commit,
// 期间收件箱可见 active 卡随 card.updated 增量刷新。
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "流式生成周报……"})
parts = append(parts, core.Part{Type: core.PartThinking, Text: "流式生成周报……", Process: true})
if _, err := rc.BeginStreamCard(core.ActivityCard{
Kind: core.CardKindResult, Title: "项目周报 · 流式", Tone: "ok",
Body: []core.UINode{{Type: core.UIKV, KV: [][2]string{{"摘要", "三段式流式产卡演示:进展 / 风险 / 下周"}}}},
}); err != nil {
addText("收件箱未启用或流式卡开始失败(" + err.Error() + "),内容直接写在对话中。")
} else {
for _, seg := range []string{
"## 进展\n\n- 卡流三段式工具上线\n- 组合验证全绿\n",
"## 风险\n\n- Gitea 主机偶发中断(重试可恢复)\n",
"## 下周\n\n- artifact 统一(CF-A)\n- 跨会话收件箱(CF3)\n",
} {
if aerr := rc.AppendStreamCard(seg); aerr != nil {
break
}
}
if done, cerr := rc.CommitStreamCard(); cerr == nil {
addText("周报已流式生成完毕,全文见右侧卡片。")
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: done.ID}})
} else {
addText("周报流式收尾失败:" + cerr.Error())
}
}
case strings.Contains(user, "#doc"):
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"})
parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true})
// 真 docx 演示(mock 专用确定性生成器——真实链路由 Agent 经 run_python +
// register_artifact 自产自登记):mock 模式即可看到「带排版 Word + 交付物
// 卡片」全链路——标题层级/表格/列表都在文档字节里。
md := "# 演示会议纪要\n\n这是 mock runner 生成的示例 Word 工件。\n\n## 基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 指令 | #doc |\n| 生成器 | docgen |\n\n## 要点\n\n1. 由 #doc 指令触发\n2. 带 Word 排版(标题/表格/编号列表)\n3. 对话内以交付物卡片呈现,点击右栏预览\n"
buf, derr := docgen.Build("", md)
if derr != nil {
addText("演示文档生成失败:" + derr.Error())
break
}
addText("Word 文档已生成——点击下方交付卡片右栏预览,或 ⌘1 工件面板查看。")
art, err := rc.WriteArtifact("demo-会议纪要.docx", "document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", buf)
if err == nil {
// 交付物钉尾(内容部件,与 llm.go 契约一致):卡片在最终正文之后。
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
// 工件门面卡演示(CF-A/D47):registry 工具大结果的统一呈现——
// 摘要 + 来源 kv + doc-ref(正文权威在工件);收件箱关闭时 SubmitCard
// 返回空 ID,跳过卡部件。
if fc, ferr := rc.SubmitCard(artifactFacadeCard("run_python", art, "演示 Word 工件已生成(#doc,带排版)")); ferr == nil && fc.ID != "" {
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
}
}
case strings.Contains(user, "#squad"):
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "调用 Agent-Squad 引擎编排……"})
parts = append(parts, core.Part{Type: core.PartThinking, Text: "调用 Agent-Squad 引擎编排……", Process: true})
goal := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(user, "#squad", ""), "#hitl", ""))
if eff := rc.Settings(); !eff.GetBool("runtime.squad", true) {
addText("小队编排已被设置禁用(runtime.squad)。")
rc.SetAssistantParts(parts)
return nil
}
dataDir := rc.Settings().GetString("storage.dataDir", "./data")
summary, err := squadadapter.Orchestrate(rc, dataDir, firstNonEmpty0(goal, "生成一份关于演示目标的简要报告"))
if err != nil {
addText("小队编排结束(" + err.Error() + ")。摘要:" + summary)
} else {
addText("Agent-Squad 编排完成:" + summary + "。步骤与子代理见右栏「计划」「代理」。")
}
case strings.Contains(user, "#workflow"):
// Q3 WP2:演示工作流同走编译图(纯宿主步,不依赖模型后端)。
res, err := (CompiledWorkflowRunner{}).runLaunch(rc, workflow.Launch{Def: mockWorkflow()}, wfcompile.RunOptions{})
if err != nil {
if res.Summary != "" {
addText(res.Summary)
}
addText("演示工作流没有跑完:" + err.Error())
break
}
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 指令)")
default:
// 默认脚本:思考 → 工具调用(知识检索)→ 回复(流式逐字)。
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "理解问题,先检索项目知识……"})
parts = append(parts, core.Part{Type: core.PartThinking, Text: "理解问题,先检索项目知识……", Process: true})
rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
callID := "call_mock_1"
rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: callID, Name: "knowledge_search", Arguments: `{"query":"` + truncateForJSON(user, 40) + `"}`})
res := rc.ExecuteTool(core.ToolCallRequest{CallID: callID, Name: "knowledge_search", Arguments: []byte(`{"query":"演示"}`)})
rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: callID, Status: res.Status, Summary: truncateForJSON(res.Output, 120), Truncated: res.Truncated})
parts = append(parts,
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: callID, Name: "knowledge_search", Arguments: `{"query":"演示"}`}},
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: callID, Status: res.Status, Summary: truncateForJSON(res.Output, 120)}},
)
rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseResponding})
reply := fmt.Sprintf("(mock 回复)你说:%s\n\n这是 mock runner 的脚本化回复——未调用真实模型。可发送 #hitl / #agents / #doc / #workflow / #error 触发对应演示路径。", user)
idx := 0
for _, r := range reply {
rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: idx, Text: string(r)})
time.Sleep(8 * time.Millisecond)
}
addText(reply)
}
rc.SetAssistantParts(parts)
return nil
}
// MockSubagent 子代理脚本回放:进度事件 + 摘要结果。
type MockSubagent struct{}
func (m *MockSubagent) Name() string { return "mock-subagent" }
func (m *MockSubagent) Execute(rc RunCtx) error {
rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "开始子任务……", Phase: core.PhaseThinking})
time.Sleep(150 * time.Millisecond)
rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "处理中……", Phase: core.PhaseActing})
time.Sleep(150 * time.Millisecond)
task := rc.Subtask()
rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: fmt.Sprintf("# 子代理产出(mock)\n\n任务:%s\n\n- 已按子代理角色完成处理\n- 本文本将作为小队步骤的交付物写入工作区\n- 任务树与中间过程可在右栏与弹窗查看\n", firstNonEmpty0(task, "(未提供任务描述)"))}})
return nil
}
func mockWorkflow() core.WorkflowDef {
return core.WorkflowDef{
ID: "mock.workflow", Title: "演示工作流",
Steps: []core.WorkflowStep{
{ID: "length", Title: "篇幅", Kind: "hitl", Hitl: &core.HITLCardTemplate{
Question: "要多长?", Kind: core.HITLKindChoice, AllowFreeInput: false,
Choices: []core.HITLChoice{
{ID: "short", Label: "短", Description: "一段", Impact: "只写一段"},
{ID: "long", Label: "长", Description: "几段", Impact: "会写得更长"},
},
}},
{ID: "save", Title: "成稿", Kind: "artifact", Artifact: "mock-brief.md", Prompt: "篇幅:{{steps.length.output}}\n{{input}}"},
{ID: "note", Title: "收件", Kind: "card", Card: &core.ActivityCardTemplate{Kind: core.CardKindResult, Title: "演示工作流完成"}},
},
}
}
func truncateForJSON(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}