Unify artifact context backfill and facade cards (CF-A) #29

Merged
weichen merged 1 commits from feature/artifact-unify-r1 into main 2026-09-30 18:42:15 +00:00
8 changed files with 345 additions and 25 deletions
+42 -8
View File
@@ -4,6 +4,7 @@ package ctxasm
import (
"fmt"
"strings"
"unicode/utf8"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
@@ -26,12 +27,17 @@ const DefaultMaxCardChars = 12000
// CardResolver 按 ID 取收件箱卡(产物卡全文回填;nil = 无卡服务/无此卡,跳过)。
type CardResolver func(id core.ID) *core.ActivityCard
// ArtifactResolver 按 ID 取工件及其内容(artifact_ref 正文回填;D46 与卡片回填
// 对称)。ok=false = 无工件服务/无此卡/内容不可读,部件留占位行。
type ArtifactResolver func(id core.ID) (art core.Artifact, content []byte, ok bool)
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
// 调用方经 prompts.Service.ResolveSystem 产出),promptObs 为对应可观测条目。
// prof.SystemPrompt 非空时作为画像自定义段追加(向后兼容旧画像)。
// 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) {
// 载体,下一轮模型不失忆),nil 时 card 部件只留占位。resolveArtifact 同理回填
// artifact_ref 工件正文(D46 统一;仅 UTF-8 文本,超 limit 截断留痕)。
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, resolveArtifact ArtifactResolver) ([]agentllm.Message, *Report) {
rep := &Report{}
msgs := make([]agentllm.Message, 0, len(sys)+1)
msgs = append(msgs, sys...)
@@ -59,11 +65,12 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
limitCard = DefaultMaxCardChars
}
cardsBackfilled := 0
artsBackfilled := 0
for _, m := range history[start:] {
if m.ID == currentUser.ID {
continue
}
content := renderMessage(m, resolveCard, limitCard, rep, &cardsBackfilled)
content := renderMessage(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)
if content == "" {
continue
}
@@ -77,6 +84,9 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
if cardsBackfilled > 0 {
rep.Items = append(rep.Items, fmt.Sprintf("card(%d)", cardsBackfilled))
}
if artsBackfilled > 0 {
rep.Items = append(rep.Items, fmt.Sprintf("artifact(%d)", artsBackfilled))
}
// 当前轮:文本 + 附件解析结果 + 知识检索注入。
var sb strings.Builder
@@ -117,8 +127,8 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
}
// renderMessage 把消息部件渲染为模型可读文本(思考部件不回灌;工具结果给摘要;
// card 引用部件回填产物卡正文——截断记入 rep.Truncations)。
func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, rep *Report, cardsBackfilled *int) string {
// card 引用部件回填产物卡正文、artifact_ref 回填工件正文——截断记入 rep.Truncations)。
func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int) string {
var sb strings.Builder
for _, p := range m.Parts {
switch p.Type {
@@ -134,9 +144,7 @@ func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, rep
fmt.Fprintf(&sb, "[工具结果 %s %s]\n", p.ToolResult.Status, p.ToolResult.Summary)
}
case core.PartArtifactRef:
if p.Artifact != nil {
fmt.Fprintf(&sb, "[产出工件 %s]\n", p.Artifact.Name)
}
sb.WriteString(renderArtifactRef(p.Artifact, resolveArtifact, limitCard, rep, artsBackfilled))
case core.PartHITL:
if p.Hitl != nil && p.Hitl.Resolved {
fmt.Fprintf(&sb, "[用户确认:%s → %s]\n", p.Hitl.Question, p.Hitl.Answer)
@@ -190,3 +198,29 @@ func renderCardPart(cp *core.CardPart, resolveCard CardResolver, limit int, rep
}
return sb.String()
}
// renderArtifactRef 渲染 artifact_ref 部件:有 resolver 且内容为 UTF-8 文本时
// 回填正文(与卡同 limit,超限截断留痕);二进制/不可读/无 resolver 留占位行
// (可见即信号,不静默丢弃)。
func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, rep *Report, backfilled *int) string {
if a == nil {
return ""
}
if resolve == nil {
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
}
art, content, ok := resolve(a.ArtifactID)
if !ok {
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
}
if !utf8.Valid(content) {
return fmt.Sprintf("[产出工件 %s(二进制内容,正文见工件查看器)]\n", art.Name)
}
*backfilled++
text := string(content)
if runes := []rune(text); len(runes) > limit {
text = string(runes[:limit]) + "…(工件超长截断)"
rep.Truncations = append(rep.Truncations, fmt.Sprintf("工件 %s 超 %d 字符截断", art.Name, limit))
}
return fmt.Sprintf("[产出工件 %s]\n%s\n", art.Name, text)
}
+67 -6
View File
@@ -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, nil)
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, 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, nil)
msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, 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, nil)
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
if len(rep.Truncations) != 0 {
t.Errorf("under-limit doc should not truncate: %v", rep.Truncations)
}
@@ -89,7 +89,7 @@ func TestAssembleInputCardBackfill(t *testing.T) {
}
return nil
}
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve)
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil)
var assistantIn string
for _, m := range msgs {
@@ -128,7 +128,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
}},
}
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 })
msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil)
if strings.Count(msgs[1].Content, "丁") != 100 {
t.Errorf("card chars = %d, want 100 (configured limit)", strings.Count(msgs[1].Content, "丁"))
}
@@ -137,8 +137,69 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
}
// 无 resolver(收件箱关闭/卡片缺失):占位可见。
msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil)
msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
if !strings.Contains(msgs2[1].Content, "内容见收件箱") {
t.Errorf("fallback placeholder missing: %q", msgs2[1].Content)
}
}
// TestAssembleInputArtifactBackfill 验证 D46:artifact_ref 与卡片回填对称——
// 文本工件回填正文(超 limit 截断留痕、报告 artifact(n));二进制留占位。
func TestAssembleInputArtifactBackfill(t *testing.T) {
prof := core.AgentProfile{SystemPrompt: "s"}
art := core.Artifact{ID: "art_1", Name: "检索汇总.md", Kind: "document"}
hist := []core.Message{
{ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{
{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "art_1", Name: art.Name, Kind: art.Kind}},
}},
}
resolve := func(id core.ID) (core.Artifact, []byte, bool) {
if id == "art_1" {
return art, []byte("## 汇总\n关键结论一。"), true
}
return core.Artifact{}, nil, false
}
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
if !strings.Contains(msgs[1].Content, "[产出工件 检索汇总.md]") || !strings.Contains(msgs[1].Content, "关键结论一。") {
t.Errorf("artifact body not backfilled: %q", msgs[1].Content)
}
if !contains(rep.Items, "artifact(1)") {
t.Errorf("artifact(n) missing from report items: %v", rep.Items)
}
// 超限截断留痕(复用 maxCardChars 上限口径)。
long := strings.Repeat("乙", 300)
eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}}
msgs2, rep2 := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
return art, []byte(long), true
})
if strings.Count(msgs2[1].Content, "乙") != 100 {
t.Errorf("artifact chars = %d, want 100", strings.Count(msgs2[1].Content, "乙"))
}
if len(rep2.Truncations) != 1 || !strings.Contains(rep2.Truncations[0], "检索汇总.md") {
t.Errorf("artifact truncation not observable: %v", rep2.Truncations)
}
// 二进制内容:占位不回灌乱码。
msgs3, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
return art, []byte{0xff, 0xfe, 0x00, 0x01}, true
})
if !strings.Contains(msgs3[1].Content, "二进制内容") || strings.Contains(msgs3[1].Content, "关键结论") {
t.Errorf("binary artifact should keep placeholder: %q", msgs3[1].Content)
}
// 无 resolver:占位可见。
msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
if !strings.Contains(msgs4[1].Content, "[产出工件 检索汇总.md]") {
t.Errorf("artifact placeholder missing without resolver: %q", msgs4[1].Content)
}
}
func contains(items []string, want string) bool {
for _, it := range items {
if it == want {
return true
}
}
return false
}
+14
View File
@@ -325,6 +325,20 @@ func (c *runCtx) Card(id string) *core.ActivityCard {
return &card
}
// Artifact:按 ID 取工件与内容(ctxasm 回填 artifact_ref 正文用;跨租户/不存在
// 一律 false,不泄露存在性)。
func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) {
art, err := c.mgr.st.GetArtifact(c.run.Owner, string(id))
if err != nil {
return core.Artifact{}, nil, false
}
content, err := c.mgr.st.ArtifactContent(c.run.Owner, string(id))
if err != nil {
return art, nil, false
}
return art, content, true
}
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 {
+33 -1
View File
@@ -95,6 +95,24 @@ func (l *LLMRunner) toolDesc(owner, name, fallback string) string {
return fallback
}
// artifactFacadeCard 构造工具大结果的门面卡(D47):kind=result、status=done,
// body = 摘要 markdown + 来源 kv + doc-ref(正文权威在工件,卡只做门面与锚)。
func artifactFacadeCard(tool string, art core.Artifact, summary string) core.ActivityCard {
md := strings.TrimSpace(summary)
if md == "" {
md = "工具结果超限,完整内容已存为工件"
}
return core.ActivityCard{
Kind: core.CardKindResult, Title: "工具大结果 · " + art.Name,
Status: core.CardDone, Tone: "info",
Body: []core.UINode{
{Type: "markdown", Markdown: md},
{Type: "kv", KV: [][2]string{{"来源工具", tool}, {"工件", art.Name}}},
{Type: "doc-ref", DocID: core.ID(art.ID), Title: art.Name},
},
}
}
func versionOf(blocks []core.PromptBlock, id string) int {
for _, b := range blocks {
if b.ModuleID == id {
@@ -160,7 +178,9 @@ 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, rc.Card)
// 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。
resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) }
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact)
if asmReport != nil {
rc.SetContextObservation(core.ContextObservation{
Items: asmReport.Items, Truncations: asmReport.Truncations, EstChars: asmReport.EstChars,
@@ -208,6 +228,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
}
var finalParts []core.Part
explicitCard := false // 本 run 已显式提交产物卡(submit_card/card_commit;门面卡避让,D47)
usageTotal := core.Usage{}
maxTurns := 16
if prof.Budget.MaxTurns != nil {
@@ -359,6 +380,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
break
}
finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
explicitCard = true
out = fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", a.Title, card.ID)
case toolCardBegin:
if !cardflow {
@@ -406,6 +428,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
break
}
finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
explicitCard = true
out = fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID)
default:
res := rc.ExecuteTool(core.ToolCallRequest{CallID: call.ID, Name: call.Name, Arguments: json.RawMessage(call.Arguments)})
@@ -416,6 +439,15 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
}
if res.Artifact != nil {
artifactRef = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
// 工具大结果门面卡(D47):截断超限的结果在收件箱留一张可发现
// 的门面(摘要 + 来源 + doc-ref),正文权威仍在工件(不复制字节)。
// 本 run 已显式提交产物卡时避让(防双卡)。
if cardflow && res.Truncated && !explicitCard {
fc, ferr := rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
if ferr == nil {
finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
}
}
}
rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: res.Status, Summary: summarize(out, err), Truncated: res.Truncated})
}
+123 -10
View File
@@ -18,15 +18,17 @@ type emittedEvent struct {
// fakeRunCtx:RunCtx 最小实现——只收集事件与终态部件,其余 no-op。
type fakeRunCtx struct {
ctx context.Context
prof core.AgentProfile
cardflow bool
events []emittedEvent
assistant []core.Part
cards []core.ActivityCard
streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义)
toolDecls []agentllm.ToolDecl
execArtifact *core.Artifact // ExecuteTool 返回的工件(非 nil 时走工件路径)
ctx context.Context
prof core.AgentProfile
cardflow bool
events []emittedEvent
assistant []core.Part
cards []core.ActivityCard
streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义)
toolDecls []agentllm.ToolDecl
execArtifact *core.Artifact // ExecuteTool 返回的工件(非 nil 时走工件路径)
execTruncated bool // ExecuteTool 返回的截断标记(门面卡触发)
execArtifactContent []byte // Artifact(id) 回填内容(ctxasm D46 断言用)
}
func (f *fakeRunCtx) Context() context.Context { return f.ctx }
@@ -57,7 +59,7 @@ func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (cor
}
func (f *fakeRunCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
res := core.ToolCallResult{Status: "ok", Output: "done"}
res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated}
if f.execArtifact != nil {
res.Artifact = f.execArtifact
}
@@ -74,6 +76,13 @@ func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, erro
f.cards = append(f.cards, card)
return card, nil
}
func (f *fakeRunCtx) Artifact(id string) (core.Artifact, []byte, bool) {
if f.execArtifact != nil && f.execArtifact.ID == id {
return *f.execArtifact, f.execArtifactContent, true
}
return core.Artifact{}, nil, false
}
func (f *fakeRunCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) {
return f.CreateCard(card)
}
@@ -468,3 +477,107 @@ func TestLLMRunnerCardStreamGated(t *testing.T) {
t.Errorf("关闭时幻觉 begin 不应产卡,got %d", len(rc.cards))
}
}
// TestLLMRunnerArtifactFacadeCard 验证 D47:截断的工具大结果自动产门面卡
// (result/done,含来源 kv 与 doc-ref),消息同时携带 card 引用与 artifact_ref;
// 未截断的结果不建卡(防收件箱噪音)。
func TestLLMRunnerArtifactFacadeCard(t *testing.T) {
turns := [][]agentllm.Event{
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "检索完成。"},
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
{ID: "c1", Name: "big_search", Arguments: `{"query":"报告"}`},
}}},
},
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "结论如上。"},
agentllm.Event{Kind: agentllm.KindMessageEnd},
},
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{
ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true,
execTruncated: true,
execArtifact: &core.Artifact{ID: "art_1", Name: "大结果.md", Kind: "document"},
}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
if len(rc.cards) != 1 {
t.Fatalf("截断结果应产 1 张门面卡,got %d", len(rc.cards))
}
fc := rc.cards[0]
if fc.Kind != core.CardKindResult || fc.Status != core.CardDone || fc.Title != "工具大结果 · 大结果.md" {
t.Errorf("门面卡 kind/status/title = %s/%s/%q", fc.Kind, fc.Status, fc.Title)
}
hasKV, hasDocRef := false, false
for _, n := range fc.Body {
if n.Type == "kv" {
hasKV = true
}
if n.Type == "doc-ref" && n.DocID == "art_1" {
hasDocRef = true
}
}
if !hasKV || !hasDocRef {
t.Errorf("门面卡 body 缺 kv/doc-ref: %+v", fc.Body)
}
hasRef, hasArt := false, false
for _, p := range rc.assistant {
if p.Type == core.PartCard && p.Card != nil && p.Card.CardID == fc.ID {
hasRef = true
}
if p.Type == core.PartArtifactRef && p.Artifact != nil && p.Artifact.ArtifactID == "art_1" {
hasArt = true
}
}
if !hasRef || !hasArt {
t.Errorf("消息应同时含门面卡引用与 artifact_ref(ref=%v art=%v)", hasRef, hasArt)
}
// 未截断:不建卡。
rc2 := &fakeRunCtx{
ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true,
execArtifact: &core.Artifact{ID: "art_2", Name: "小结果.md", Kind: "document"},
}
r2 := &LLMRunner{Stream: scriptStream(turns, nil)}
if err := r2.Execute(rc2); err != nil {
t.Fatalf("Execute(not truncated): %v", err)
}
if len(rc2.cards) != 0 {
t.Errorf("未截断结果不应建门面卡,got %d", len(rc2.cards))
}
}
// TestLLMRunnerArtifactFacadeSuppressed 验证 D47 防双卡:本 run 已显式
// submit_card 时,截断的工具大结果不再自动建门面卡。
func TestLLMRunnerArtifactFacadeSuppressed(t *testing.T) {
turns := [][]agentllm.Event{
{
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
{ID: "c1", Name: "submit_card", Arguments: `{"title":"正式产物","summary":"摘要","body":"# 正文"}`},
}}},
},
{
agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{
{ID: "c2", Name: "big_search", Arguments: `{"query":"x"}`},
}}},
},
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "完成。"},
agentllm.Event{Kind: agentllm.KindMessageEnd},
},
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{
ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true,
execTruncated: true,
execArtifact: &core.Artifact{ID: "art_9", Name: "附属.md", Kind: "document"},
}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
if len(rc.cards) != 1 || rc.cards[0].Title != "正式产物" {
t.Errorf("显式卡存在时不应有门面卡,got %+v", rc.cards)
}
}
+6
View File
@@ -175,6 +175,12 @@ func (m *MockRunner) Execute(rc RunCtx) error {
art, err := rc.WriteArtifact("demo-报告.md", "document", "text/markdown", []byte(content))
if err == nil {
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("write_artifact", art, "演示工件已生成(#doc)")); ferr == nil && fc.ID != "" {
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
}
}
addText("文档已生成,见右侧 Artifacts 标签(也可点击消息中的工件引用)。")
+2
View File
@@ -45,6 +45,8 @@ type RunCtx interface {
CommitStreamCard() (core.ActivityCard, error)
// Card:按 ID 取卡(ctxasm 回填 card 部件正文用;无则 nil)。
Card(id string) *core.ActivityCard
// Artifact:按 ID 取工件与内容(ctxasm 回填 artifact_ref 正文用;无则 false)。
Artifact(id string) (core.Artifact, []byte, bool)
// SetContextObservation 记录本次运行的上下文组装观测(随 run.completed 下发)。
SetContextObservation(o core.ContextObservation)
}
+58
View File
@@ -447,6 +447,64 @@ func TestArtifactFlow(t *testing.T) {
}
}
// TestArtifactFacadeCard 工件门面卡全链路(CF-A/D47):mock #doc → 工件落库 +
// artifact_ref 部件 → 门面卡入收件箱(摘要 kv + doc-ref)→ 消息含 card 引用 →
// finishRun 锚点回填。
func TestArtifactFacadeCard(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
runID := postMessage(t, h, convID, "#doc")
terminal := waitRunTerminal(t, h, runID, 5*time.Second)
if terminal["status"] != "completed" {
t.Fatalf("run status: %v", terminal["status"])
}
_, cardsResp := doJSON(t, h, "GET", "/api/conversations/"+convID+"/cards", nil)
cards, _ := cardsResp["cards"].([]any)
var facade map[string]any
for _, ci := range cards {
c := ci.(map[string]any)
if c["kind"] == "result" && c["title"] == "工具大结果 · demo-报告.md" {
facade = c
}
}
if facade == nil {
t.Fatalf("facade card missing from inbox: %v", cards)
}
if facade["status"] != "done" {
t.Errorf("facade status = %v, want done", facade["status"])
}
hasDocRef := false
for _, bi := range facade["body"].([]any) {
n := bi.(map[string]any)
if n["type"] == "doc-ref" && n["docId"] != "" {
hasDocRef = true
}
}
if !hasDocRef {
t.Error("facade body missing doc-ref node")
}
_, detail := doJSON(t, h, "GET", "/api/conversations/"+convID, nil)
msgs := detail["messages"].([]any)
assistant := msgs[len(msgs)-1].(map[string]any)
cardID := facade["id"].(string)
hasRef := false
for _, pi := range assistant["parts"].([]any) {
p := pi.(map[string]any)
if p["type"] == "card" && p["card"].(map[string]any)["cardId"] == cardID {
hasRef = true
}
}
if !hasRef {
t.Errorf("assistant message missing facade card ref %s", cardID)
}
if anchor, _ := facade["anchorMessageId"].(string); anchor != assistant["id"].(string) {
t.Errorf("facade anchorMessageId = %q, want %q", anchor, assistant["id"])
}
}
func TestUploadParseAndKnowledge(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()