diff --git a/deps/agent-system-core b/deps/agent-system-core index fd44179..a64fb5a 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit fd441794b1e7bd9ac69b73b8722c93f599892c7a +Subproject commit a64fb5a168f2e44358b186265b893c4eec5dd1a5 diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index c8c9bd8..3820ca4 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -20,10 +20,18 @@ type Report struct { // 16k 时代码值对会议纪要类长文档明显不足(正文大半被静默丢弃);48k ≈ 主流 128k 上下文的安全占比。 const DefaultMaxAttachmentChars = 48000 +// DefaultMaxCardChars 单张产物卡回填注入的默认字符上限(settings context.maxCardChars 可调)。 +const DefaultMaxCardChars = 12000 + +// CardResolver 按 ID 取收件箱卡(产物卡全文回填;nil = 无卡服务/无此卡,跳过)。 +type CardResolver func(id core.ID) *core.ActivityCard + // AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段; // 调用方经 prompts.Service.ResolveSystem 产出),promptObs 为对应可观测条目。 // prof.SystemPrompt 非空时作为画像自定义段追加(向后兼容旧画像)。 -func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string) ([]agentllm.Message, *Report) { +// resolveCard 用于 card 引用部件回填产物卡正文(submit_card;D35:卡片为产物权威 +// 载体,下一轮模型不失忆),nil 时 card 部件只留占位。 +func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver) ([]agentllm.Message, *Report) { rep := &Report{} msgs := make([]agentllm.Message, 0, len(sys)+1) msgs = append(msgs, sys...) @@ -46,11 +54,16 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ start = len(history) - maxHist rep.Truncations = append(rep.Truncations, fmt.Sprintf("历史 %d 条超窗口 %d,仅保留最近部分", len(history), maxHist)) } + limitCard := int(eff.GetNumber("context.maxCardChars", DefaultMaxCardChars)) + if limitCard <= 0 { + limitCard = DefaultMaxCardChars + } + cardsBackfilled := 0 for _, m := range history[start:] { if m.ID == currentUser.ID { continue } - content := renderMessage(m) + content := renderMessage(m, resolveCard, limitCard, rep, &cardsBackfilled) if content == "" { continue } @@ -61,6 +74,9 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ msgs = append(msgs, agentllm.Message{Role: role, Content: content}) } rep.Items = append(rep.Items, fmt.Sprintf("history(%d)", len(history)-start)) + if cardsBackfilled > 0 { + rep.Items = append(rep.Items, fmt.Sprintf("card(%d)", cardsBackfilled)) + } // 当前轮:文本 + 附件解析结果 + 知识检索注入。 var sb strings.Builder @@ -100,8 +116,9 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ return msgs, rep } -// renderMessage 把消息部件渲染为模型可读文本(思考部件不回灌;工具结果给摘要)。 -func renderMessage(m core.Message) string { +// renderMessage 把消息部件渲染为模型可读文本(思考部件不回灌;工具结果给摘要; +// card 引用部件回填产物卡正文——截断记入 rep.Truncations)。 +func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, rep *Report, cardsBackfilled *int) string { var sb strings.Builder for _, p := range m.Parts { switch p.Type { @@ -124,7 +141,52 @@ func renderMessage(m core.Message) string { if p.Hitl != nil && p.Hitl.Resolved { fmt.Fprintf(&sb, "[用户确认:%s → %s]\n", p.Hitl.Question, p.Hitl.Answer) } + case core.PartCard: + sb.WriteString(renderCardPart(p.Card, resolveCard, limitCard, rep, cardsBackfilled)) } } return strings.TrimSpace(sb.String()) } + +// renderCardPart 渲染 card 部件:引用部件(cardId)回填产物卡全文(超限截断留痕); +// 无 resolver 或无此卡时留占位(可见即信号,不静默丢弃)。 +func renderCardPart(cp *core.CardPart, resolveCard CardResolver, limit int, rep *Report, cardsBackfilled *int) string { + if cp == nil { + return "" + } + if cp.CardID == "" || resolveCard == nil { + return fmt.Sprintf("[卡片 %s(内容见收件箱)]\n", cp.Kind) + } + card := resolveCard(cp.CardID) + if card == nil { + return fmt.Sprintf("[卡片 %s(内容见收件箱)]\n", cp.Kind) + } + *cardsBackfilled++ + var sb strings.Builder + fmt.Fprintf(&sb, "[产物卡片:%s]\n", card.Title) + total := 0 + for _, n := range card.Body { + switch n.Type { + case "markdown": + runes := []rune(n.Markdown) + if total+len(runes) > limit { + keep := limit - total + if keep < 0 { + keep = 0 + } + rep.Truncations = append(rep.Truncations, fmt.Sprintf("卡片 %s 超 %d 字符截断", card.Title, limit)) + sb.WriteString(string(runes[:keep])) + sb.WriteString("…(卡片超长截断)\n") + return sb.String() + } + total += len(runes) + sb.WriteString(n.Markdown) + sb.WriteString("\n") + case "kv": + for _, kv := range n.KV { + fmt.Fprintf(&sb, "%s:%s\n", kv[0], kv[1]) + } + } + } + return sb.String() +} diff --git a/internal/ctxasm/ctxasm_test.go b/internal/ctxasm/ctxasm_test.go index d8d48e6..8f8419b 100644 --- a/internal/ctxasm/ctxasm_test.go +++ b/internal/ctxasm/ctxasm_test.go @@ -18,7 +18,7 @@ func TestAssembleInputAttachmentLimit(t *testing.T) { Meta: core.DocMeta{Filename: "纪要.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}, } - msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil) + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil) last := msgs[len(msgs)-1] if !strings.Contains(last.Content, "纪要.docx") { @@ -42,7 +42,7 @@ func TestAssembleInputAttachmentLimitConfigurable(t *testing.T) { Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("甲", 120)}}, } eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}} - msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil) + msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil) last := msgs[len(msgs)-1] if got := strings.Count(last.Content, "甲"); got != 100 { t.Errorf("injected chars = %d, want 100 (configured limit)", got) @@ -58,7 +58,7 @@ func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) { Meta: core.DocMeta{Filename: "b.txt"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("乙", 100)}}, } - msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil) + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil) if len(rep.Truncations) != 0 { t.Errorf("under-limit doc should not truncate: %v", rep.Truncations) } @@ -66,3 +66,79 @@ func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) { t.Error("full text should be injected") } } + +// TestAssembleInputCardBackfill 验证 card 引用部件回填产物卡全文(D35:卡片为产物 +// 权威载体,下一轮模型不失忆),超限截断入 Truncations,构成入 Items 的 card(n)。 +func TestAssembleInputCardBackfill(t *testing.T) { + prof := core.AgentProfile{SystemPrompt: "s"} + full := "全文第一段。\n" + strings.Repeat("丙", 50) + card := core.ActivityCard{ID: "card_1", Title: "会议纪要", Body: []core.UINode{ + {Type: "markdown", Markdown: full}, + {Type: "kv", KV: [][2]string{{"摘要", "三项决议"}}}, + }} + hist := []core.Message{ + fakeUser("生成纪要"), + {ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{ + {Type: core.PartText, Text: "已完成,详见卡片。"}, + {Type: core.PartCard, Card: &core.CardPart{Kind: "result", CardID: "card_1"}}, + }}, + } + resolve := func(id core.ID) *core.ActivityCard { + if string(id) == "card_1" { + return &card + } + return nil + } + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve) + + var assistantIn string + for _, m := range msgs { + if m.Role == "assistant" { + assistantIn = m.Content + } + } + if !strings.Contains(assistantIn, "[产物卡片:会议纪要]") || !strings.Contains(assistantIn, "全文第一段。") { + t.Errorf("card body not backfilled into model input: %q", assistantIn) + } + if !strings.Contains(assistantIn, "摘要:三项决议") { + t.Error("kv summary not backfilled") + } + found := false + for _, it := range rep.Items { + if it == "card(1)" { + found = true + } + } + if !found { + t.Errorf("card(n) missing from report items: %v", rep.Items) + } + if len(rep.Truncations) != 0 { + t.Errorf("under-limit card should not truncate: %v", rep.Truncations) + } +} + +// TestAssembleInputCardTruncationAndFallback 超限截断留痕;无 resolver 时留占位不静默丢弃。 +func TestAssembleInputCardTruncationAndFallback(t *testing.T) { + prof := core.AgentProfile{SystemPrompt: "s"} + long := strings.Repeat("丁", 20000) + card := core.ActivityCard{ID: "card_2", Title: "长报告", Body: []core.UINode{{Type: "markdown", Markdown: long}}} + hist := []core.Message{ + {ID: "m9", Role: core.RoleAssistant, Parts: []core.Part{ + {Type: core.PartCard, Card: &core.CardPart{Kind: "result", CardID: "card_2"}}, + }}, + } + eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}} + msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }) + if strings.Count(msgs[1].Content, "丁") != 100 { + t.Errorf("card chars = %d, want 100 (configured limit)", strings.Count(msgs[1].Content, "丁")) + } + if len(rep.Truncations) != 1 || !strings.Contains(rep.Truncations[0], "长报告") { + t.Errorf("card truncation not observable: %v", rep.Truncations) + } + + // 无 resolver(收件箱关闭/卡片缺失):占位可见。 + msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil) + if !strings.Contains(msgs2[1].Content, "内容见收件箱") { + t.Errorf("fallback placeholder missing: %q", msgs2[1].Content) + } +} diff --git a/internal/prompts/library/style-cardflow.md b/internal/prompts/library/style-cardflow.md new file mode 100644 index 0000000..17ec171 --- /dev/null +++ b/internal/prompts/library/style-cardflow.md @@ -0,0 +1,16 @@ +--- +id: style.cardflow +layer: style +version: 1 +title: 长结果进卡片 +description: 产物型长内容经 submit_card 进右侧收件箱,对话只留结论摘要(设置 ui.cardflow 开启时注入) +outputContract: | + 1. 判定内容类型:报告、纪要、方案、对比、文档草稿、结构化清单 = 产物型;解释、澄清、追问、简短结论 = 对话型 + 2. 产物型长内容(预期超过约 300 字或含表格/多级结构):调用 submit_card(title, summary, body) 提交,卡片承载全文 + 3. 对话中只保留结论先行的简短摘要(要点 + 一句话结果),并自然提及"详见右侧卡片" + 4. 对话型内容(含解释型长回答)一律直接写在对话里,不进卡片 + 5. 单次任务至多一张产物卡;不要把回答拆成多张小卡 +--- +你具备把产物型长内容放进右侧收件箱的能力:当任务的交付物是报告、纪要、方案、对比或文档草稿这类"产物"时,调用 submit_card 工具提交全文(title 为产物标题,summary 为一两句结论摘要,body 为完整 Markdown 正文),随后在对话里给出结论先行的简短回复并自然提及产物已放入右侧卡片(如"已完成,要点如下…全文见右侧卡片")。 + +边界:解释型长回答(用户问"为什么/怎么做"的讲解)即使很长也直接写在对话里——卡片只承载"产物",不承载"解释"。拆分拿不准时留在对话里(不丢内容的安全侧)。 diff --git a/internal/prompts/service.go b/internal/prompts/service.go index 69befa3..8546409 100644 --- a/internal/prompts/service.go +++ b/internal/prompts/service.go @@ -289,6 +289,17 @@ func (s *Service) AgentPrompt(owner, id string) (string, error) { return m.Body, nil } +// ModuleBody 取指定模块正文(用户覆盖优先;found=false 表示无此模块)。 +// 供设置驱动的模块注入(如 ui.cardflow → style.cardflow)——不走 PromptModules 显式引用, +// 避免向空 PromptModules 追加会翻转 pick() 的"显式引用"语义。 +func (s *Service) ModuleBody(owner, id string) (string, bool, error) { + m, ok, err := s.Get(owner, id) + if err != nil || !ok { + return "", false, err + } + return m.Body, true, nil +} + // ToolPrompt 取工具描述文案(tool 层;found=false 表示库未覆盖该工具,调用方回落注册表描述)。 func (s *Service) ToolPrompt(owner, toolName string) (string, bool, error) { m, ok, err := s.Get(owner, "tool."+toolName) diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index fc0cacd..432e2cf 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -49,11 +49,12 @@ type Manager struct { var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run") type runHandle struct { - cancel context.CancelFunc - hitlCh chan hitlAnswer - assistant []core.Part - ctxObs *core.ContextObservation - started time.Time + cancel context.CancelFunc + hitlCh chan hitlAnswer + assistant []core.Part + ctxObs *core.ContextObservation + submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点) + started time.Time } type hitlAnswer struct { @@ -229,6 +230,30 @@ func (c *runCtx) SetCardStatus(id string, status core.CardStatus, tone, note str return err } +// SubmitCard:submit_card 工具入口——建卡并登记 ID,终态消息落库后回填锚点。 +func (c *runCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) { + created, err := c.CreateCard(card) + if err != nil { + return created, err + } + c.mgr.mu.Lock() + c.h.submittedCards = append(c.h.submittedCards, created.ID) + c.mgr.mu.Unlock() + return created, nil +} + +// Card:按 ID 取卡(ctxasm 回填;无此卡或收件箱关闭返回 nil)。 +func (c *runCtx) Card(id string) *core.ActivityCard { + if c.mgr.cards == nil { + return nil + } + card, err := c.mgr.cards.Get(c.run.Owner, id) + if err != nil { + return nil + } + return &card +} + func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID} if err := c.mgr.st.CreateArtifact(&art, content); err != nil { @@ -487,6 +512,18 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, msg := core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts} if err := m.st.AppendMessage(&msg); err == nil { // 消息树权威已落库;hitl 摘要部件的卡片状态由前端对账收敛。 + // submit_card 产物卡回填锚点:卡→消息互定位(D32 方向补全)。 + m.mu.Lock() + cardIDs := append([]core.ID{}, rc.h.submittedCards...) + m.mu.Unlock() + if m.cards != nil { + for _, id := range cardIDs { + if card, err := m.cards.Get(rc.run.Owner, string(id)); err == nil { + card.AnchorMessageID = msg.ID + _ = m.cards.Update(rc.run.Owner, card) + } + } + } } if errPart != nil { m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}}) diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 34d4565..ad22d47 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -30,6 +30,7 @@ const ( toolPlan = "update_plan" toolTodos = "update_todos" toolSquad = "squad_orchestrate" + toolCard = "submit_card" // 产物卡提交(ui.cardflow 开启时声明;D39 显式路由) ) // resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目。 @@ -56,6 +57,14 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, if err != nil { return nil, []string{"prompt(库解析失败:" + err.Error() + ")"} } + // 卡流注入(D39/D42):ui.cardflow 开启时附加 style.cardflow 模块正文。 + // 不走 PromptModules(向空列表追加会翻转 pick 的"显式引用"语义),独立成块。 + if rc.Settings().GetBool("ui.cardflow", false) { + if body, ok, err := l.Prompts.ModuleBody(prof.Owner, "style.cardflow"); err == nil && ok { + blocks = append(blocks, core.PromptBlock{ModuleID: "style.cardflow", Layer: core.LayerStyle, Body: body}) + hits = append(hits, core.PromptHitRecord{ModuleID: "style.cardflow", Injected: true, Reason: "setting(ui.cardflow)"}) + } + } msgs := make([]agentllm.Message, 0, len(blocks)) var obs []string for _, b := range blocks { @@ -146,7 +155,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { if l.Prompts != nil { sys, promptObs = l.resolveSystem(rc, route.Model) } - msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs) + msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card) if asmReport != nil { rc.SetContextObservation(core.ContextObservation{ Items: asmReport.Items, Truncations: asmReport.Truncations, EstChars: asmReport.EstChars, @@ -160,6 +169,14 @@ func (l *LLMRunner) Execute(rc RunCtx) error { {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"]}`)}, {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"]}`)}, } + cardflow := eff.GetBool("ui.cardflow", false) + if cardflow { + decls = append(decls, agentllm.ToolDecl{ + Name: toolCard, + Description: l.toolDesc(prof.Owner, toolCard, "提交产物型长内容(报告/纪要/方案/对比/文档草稿)为右侧收件箱卡片:卡片承载全文,对话中只保留结论摘要。解释型回答不要用此工具"), + Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string","description":"产物标题"},"summary":{"type":"string","description":"一两句结论摘要(显示在卡片与对话锚点上)"},"body":{"type":"string","description":"完整 Markdown 正文"}},"required":["title","summary","body"]}`), + }) + } var toolDescs []core.ToolDesc for _, t := range toolList(rc) { if prof.Tools.ToolAllowed(t.Name) { @@ -299,6 +316,30 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } else { out = "待办参数错误: " + err.Error() } + case toolCard: + if !cardflow { + // 未声明却被幻觉调用:拒绝执行(不产卡),明确告知模型该能力未启用。 + out = "submit_card 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里" + break + } + var a struct{ Title, Summary, Body string } + if err := json.Unmarshal([]byte(call.Arguments), &a); err != nil || strings.TrimSpace(a.Title) == "" || strings.TrimSpace(a.Body) == "" { + out = "submit_card 参数错误(title/body 必填): " + err.Error() + break + } + body := []core.UINode{{Type: "markdown", Markdown: a.Body}} + if strings.TrimSpace(a.Summary) != "" { + body = append(body, core.UINode{Type: "kv", KV: [][2]string{{"摘要", a.Summary}}}) + } + card, cerr := rc.SubmitCard(core.ActivityCard{ + Kind: core.CardKindResult, Title: a.Title, Status: core.CardDone, Tone: "ok", Body: body, + }) + if cerr != nil { + out = "submit_card 失败: " + cerr.Error() + break + } + finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}}) + out = fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", a.Title, card.ID) default: res := rc.ExecuteTool(core.ToolCallRequest{CallID: call.ID, Name: call.Name, Arguments: json.RawMessage(call.Arguments)}) if res.Status != "ok" { @@ -311,7 +352,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: res.Status, Summary: summarize(out, err), Truncated: res.Truncated}) } - if call.Name == toolDelegate || call.Name == toolPlan || call.Name == toolTodos || call.Name == toolSquad { + if call.Name == toolDelegate || call.Name == toolPlan || call.Name == toolTodos || call.Name == toolSquad || call.Name == toolCard { rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: "ok", Summary: summarize(out, err)}) } diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index 9e7d374..bf09cf3 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -18,8 +18,11 @@ type emittedEvent struct { type fakeRunCtx struct { ctx context.Context prof core.AgentProfile + cardflow bool events []emittedEvent assistant []core.Part + cards []core.ActivityCard + toolDecls []agentllm.ToolDecl execArtifact *core.Artifact // ExecuteTool 返回的工件(非 nil 时走工件路径) } @@ -27,7 +30,11 @@ func (f *fakeRunCtx) Context() context.Context { return f.ctx } func (f *fakeRunCtx) Run() core.Run { return core.Run{ID: "run_t"} } func (f *fakeRunCtx) Profile() core.AgentProfile { return f.prof } func (f *fakeRunCtx) Settings() core.EffectiveSettings { - return core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{"baseUrl": "http://test"}}} + eff := core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{"baseUrl": "http://test"}}} + if f.cardflow { + eff["ui"] = map[string]any{"cardflow": true} + } + return eff } func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error { f.events = append(f.events, emittedEvent{typ, payload}) @@ -60,8 +67,21 @@ func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil } func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil } func (f *fakeRunCtx) Subtask() string { return "" } func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) { + card.ID = core.ID(fmt.Sprintf("card_%d", len(f.cards)+1)) + f.cards = append(f.cards, card) return card, nil } +func (f *fakeRunCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) { + return f.CreateCard(card) +} +func (f *fakeRunCtx) Card(id string) *core.ActivityCard { + for i := range f.cards { + if string(f.cards[i].ID) == id { + return &f.cards[i] + } + } + return nil +} func (f *fakeRunCtx) SetCardStatus(id string, status core.CardStatus, tone, note string) error { return nil } @@ -69,9 +89,13 @@ func (f *fakeRunCtx) SetContextObservation(o core.ContextObservation) {} // scriptStream:每次调用回放一轮(真实客户端语义:一个 message_end 后关通道)。 // 轮数耗尽后再调用返回空流(防御;正常终轮后 runner 不再调用)。 -func scriptStream(turns [][]agentllm.Event) agentllm.StreamFunc { +// declSink 非空时捕获一次工具声明清单(断言 submit_card 门控用)。 +func scriptStream(turns [][]agentllm.Event, declSink *[]agentllm.ToolDecl) agentllm.StreamFunc { n := 0 return func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { + if declSink != nil && *declSink == nil { + *declSink = tools + } out := make(chan agentllm.Event) var evs []agentllm.Event if n < len(turns) { @@ -108,7 +132,7 @@ func TestLLMRunnerToolTurnTextIsProcess(t *testing.T) { agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } - r := &LLMRunner{Stream: scriptStream(turns)} + r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { @@ -156,7 +180,7 @@ func TestLLMRunnerPhaseEventsPerTurn(t *testing.T) { agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } - r := &LLMRunner{Stream: scriptStream(turns)} + r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { @@ -200,7 +224,7 @@ func TestLLMRunnerArtifactPartIsProcess(t *testing.T) { agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } - r := &LLMRunner{Stream: scriptStream(turns)} + r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, @@ -228,3 +252,75 @@ func TestLLMRunnerArtifactPartIsProcess(t *testing.T) { t.Fatalf("未找到 artifact_ref 部件: %+v", rc.assistant) } } + +// TestLLMRunnerSubmitCardGatedBySetting 验证 D39/D42:ui.cardflow 开启时 submit_card +// 才声明并产 result 卡 + 引用部件;关闭时工具不声明、不产卡(行为等价现状)。 +func TestLLMRunnerSubmitCardGatedBySetting(t *testing.T) { + cardArgs := `{"title":"会议纪要","summary":"三项决议","body":"# 会议纪要\n全文……"}` + turnsWithCard := [][]agentllm.Event{ + { + agentllm.Event{Kind: agentllm.KindTextDelta, Text: "已完成,详见卡片。"}, + agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ + {ID: "c1", Name: "submit_card", Arguments: cardArgs}, + }}}, + }, + { + agentllm.Event{Kind: agentllm.KindTextDelta, Text: "要点如上。"}, + agentllm.Event{Kind: agentllm.KindMessageEnd}, + }, + } + + // 开启:声明工具、产卡、部件带 cardId。 + var declsOn []agentllm.ToolDecl + r := &LLMRunner{Stream: scriptStream(turnsWithCard, &declsOn)} + rcOn := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true} + if err := r.Execute(rcOn); err != nil { + t.Fatalf("Execute(cardflow on): %v", err) + } + if !hasToolDecl(declsOn, "submit_card") { + t.Error("submit_card 应在开启时声明") + } + if len(rcOn.cards) != 1 { + t.Fatalf("开启时应恰好产 1 张卡,got %d", len(rcOn.cards)) + } + if rcOn.cards[0].Kind != core.CardKindResult || rcOn.cards[0].Status != core.CardDone { + t.Errorf("卡 kind/status = %s/%s, want result/done", rcOn.cards[0].Kind, rcOn.cards[0].Status) + } + var refPart *core.Part + for i := range rcOn.assistant { + if rcOn.assistant[i].Type == core.PartCard { + refPart = &rcOn.assistant[i] + } + } + if refPart == nil || refPart.Card == nil || string(refPart.Card.CardID) != string(rcOn.cards[0].ID) { + t.Errorf("消息应含 cardId=%s 的引用部件", rcOn.cards[0].ID) + } + + // 关闭:不声明、不产卡(调用落入注册表 default 路径,fake 返回 ok 但无卡)。 + var declsOff []agentllm.ToolDecl + r2 := &LLMRunner{Stream: scriptStream(turnsWithCard, &declsOff)} + rcOff := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} + if err := r2.Execute(rcOff); err != nil { + t.Fatalf("Execute(cardflow off): %v", err) + } + if hasToolDecl(declsOff, "submit_card") { + t.Error("submit_card 不应在关闭时声明") + } + if len(rcOff.cards) != 0 { + t.Errorf("关闭时不应产卡,got %d", len(rcOff.cards)) + } + for _, p := range rcOff.assistant { + if p.Type == core.PartCard { + t.Error("关闭时消息不应含卡片部件") + } + } +} + +func hasToolDecl(decls []agentllm.ToolDecl, name string) bool { + for _, d := range decls { + if d.Name == name { + return true + } + } + return false +} diff --git a/internal/runtime/mock.go b/internal/runtime/mock.go index 3616d69..517d632 100644 --- a/internal/runtime/mock.go +++ b/internal/runtime/mock.go @@ -125,6 +125,21 @@ func (m *MockRunner) Execute(rc RunCtx) error { }) 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, "#doc"): rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"}) parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true}) diff --git a/internal/runtime/runner.go b/internal/runtime/runner.go index fb2343e..b3e4523 100644 --- a/internal/runtime/runner.go +++ b/internal/runtime/runner.go @@ -33,6 +33,11 @@ type RunCtx interface { // CreateCard / SetCardStatus:产收件箱任务卡(收件箱关闭时为 no-op)。 CreateCard(card core.ActivityCard) (core.ActivityCard, error) SetCardStatus(id string, status core.CardStatus, tone, note string) error + // SubmitCard:产物卡提交(submit_card 工具)——建卡并登记,finishRun 落库助手 + // 消息后回填 AnchorMessageID(卡→消息互定位)。 + SubmitCard(card core.ActivityCard) (core.ActivityCard, error) + // Card:按 ID 取卡(ctxasm 回填 card 部件正文用;无则 nil)。 + Card(id string) *core.ActivityCard // SetContextObservation 记录本次运行的上下文组装观测(随 run.completed 下发)。 SetContextObservation(o core.ContextObservation) } diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index 1c51dbc..4adce47 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -53,7 +53,9 @@ func newTestServer(t *testing.T) (*Server, *store.Store) { os.MkdirAll(pluginDir, 0o755) ph := plugins.New(st, reg, setSvc, pluginDir) srv := New(st, mgr, reg, profSvc, setSvc, ph) - srv.Cards = cardsvc.New(st, func(cid, runID string, ev core.StreamEventType, payload any) error { return nil }) + cardSvc := cardsvc.New(st, func(cid, runID string, ev core.StreamEventType, payload any) error { return nil }) + srv.Cards = cardSvc + mgr.SetCards(cardSvc) // run 内产卡(摄取/编排/submit_card)经 runmgr,与 app.go 装配一致 srv.Bus = cmdbus.New(st, srv.Cards, nil) srv.WireBuiltinCommands() srv.Subscribe = func(cid string) (<-chan core.StreamEvent, func()) { return b.Subscribe(cid) } @@ -134,6 +136,58 @@ func waitRunTerminal(t *testing.T, h http.Handler, runID string, timeout time.Du return nil } +// TestMockCardflowFullLoop 卡流全链路(D39/D35/D32):mock #cardflow → result 卡入收件箱 +// → 助手消息含 cardId 引用部件 → finishRun 回填锚点(卡→消息互定位)。 +func TestMockCardflowFullLoop(t *testing.T) { + srv, _ := newTestServer(t) + h := srv.Handler() + _, convID := setupConversation(t, h) + runID := postMessage(t, h, convID, "#cardflow") + terminal := waitRunTerminal(t, h, runID, 5*time.Second) + if terminal["status"] != "completed" { + t.Fatalf("run status: %v", terminal["status"]) + } + + // 收件箱出现 result 卡。 + _, cardsResp := doJSON(t, h, "GET", "/api/conversations/"+convID+"/cards", nil) + cards, _ := cardsResp["cards"].([]any) + var resultCard map[string]any + for _, ci := range cards { + c := ci.(map[string]any) + if c["kind"] == "result" && c["title"] == "会议纪要 · 第 12 次周会" { + resultCard = c + } + } + if resultCard == nil { + t.Fatalf("result card missing from inbox: %v", cards) + } + + // 助手消息含 cardId 引用部件(卡片为产物权威,消息只存引用)。 + _, detail := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) + msgs := detail["messages"].([]any) + assistant := msgs[len(msgs)-1].(map[string]any) + cardID := resultCard["id"].(string) + refFound := false + for _, pi := range assistant["parts"].([]any) { + p := pi.(map[string]any) + if p["type"] != "card" { + continue + } + cp := p["card"].(map[string]any) + if cp["cardId"] == cardID { + refFound = true + } + } + if !refFound { + t.Errorf("assistant message missing card-part with cardId=%s", cardID) + } + + // finishRun 回填锚点:卡→消息互定位(D32 方向)。 + if anchor, _ := resultCard["anchorMessageId"].(string); anchor != assistant["id"].(string) { + t.Errorf("card anchorMessageId = %q, want assistant message %q", anchor, assistant["id"]) + } +} + func TestMockConversationFlow(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 49535fa..2bf47d8 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -46,7 +46,7 @@ func SystemDefaults() map[string]any { "read": false, // 跨会话读取项目闸门(提案 2026-09-30-cross-session-reading):关 = 全项目禁读 }, "retrieval": map[string]any{"topK": 5}, - "ui": map[string]any{"theme": "dark"}, + "ui": map[string]any{"theme": "dark", "cardflow": false}, "plugins": map[string]any{"dir": "./plugins"}, } } @@ -75,6 +75,7 @@ func Schema() []core.SettingDesc { {Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"}, {Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot}, {Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot}, + {Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点"}, {Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"}, {Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"}, {Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},