Unify artifact context backfill and facade cards (CF-A) #29
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 标签(也可点击消息中的工件引用)。")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user