Scope material search fallback to current attachments

Why: combined filename and Chinese-keyword queries returned no hits even when the uploaded material was present, delaying useful background findings.

Evidence: combined-query, owner/project, duplicate, resource-policy, version-drift and excerpt RED→GREEN tests; SQLite and isolated PostgreSQL pass; Backend full vet and tests pass with the Agent-Native pin.

Impact: only a no-hit material subagent query may read a verified current-task attachment snapshot; general knowledge retrieval remains unchanged.

Refs: U3 r0.4 design; internal/runmgr/material_retrieve.go
This commit is contained in:
2026-10-14 02:24:47 +00:00
parent 4363a29653
commit 495e72adac
3 changed files with 289 additions and 5 deletions
+147
View File
@@ -0,0 +1,147 @@
package runmgr
import (
"crypto/sha256"
"encoding/hex"
"strings"
core "agent-system-core"
)
// materialRetrievalResult keeps ordinary retrieval authoritative. A no-hit
// material child may fall back only to the attachments frozen for this task.
func (c *runCtx) materialRetrievalResult(q core.RetrievalQuery, hits []core.RetrievalHit, err error) ([]core.RetrievalHit, error) {
if err != nil || len(hits) != 0 || c.subAgent != materialSubagentID {
return hits, err
}
return c.materialAttachmentFallback(q)
}
func (c *runCtx) materialAttachmentFallback(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
if c.task.cardID == "" || c.run.Owner == "" || c.run.ProjectID == "" ||
q.ProjectID != c.run.ProjectID || strings.TrimSpace(q.Text) == "" {
return nil, nil
}
if len(q.Sources) > 0 {
allowed := false
for _, source := range q.Sources {
allowed = allowed || source == "upload"
}
if !allowed {
return nil, nil
}
}
var matched *core.ParsedDocument
for i := range c.attachments {
name := strings.TrimSpace(c.attachments[i].Meta.Filename)
if name == "" || !strings.Contains(q.Text, name) {
continue
}
if matched != nil {
return nil, nil // duplicate or multiple filenames: never guess
}
matched = &c.attachments[i]
}
if matched == nil || matched.Meta.Origin == nil {
return nil, nil
}
origin := matched.Meta.Origin
if origin.ArtifactID == "" || origin.ContentHash == "" || origin.ParserRevision == "" {
return nil, nil
}
card, err := c.mgr.st.GetCard(c.run.Owner, c.task.cardID)
if err != nil {
return nil, err
}
s := card.Task
if s == nil || s.Terminal() || s.TaskID != c.task.taskID ||
s.AttemptID != c.task.attemptID || s.RunID != c.run.ID ||
s.OwnerInstanceID != c.task.instanceID || s.ExecutionEpoch != c.task.epoch {
return nil, nil
}
bound := 0
for _, input := range s.Inputs {
if input.ArtifactID == string(origin.ArtifactID) && input.ContentHash == origin.ContentHash &&
input.ParserRevision == origin.ParserRevision {
bound++
}
}
if bound != 1 {
return nil, nil
}
a, err := c.mgr.st.GetArtifact(c.run.Owner, string(origin.ArtifactID))
if err != nil {
return nil, err
}
if a.ProjectID != c.run.ProjectID || a.Name != matched.Meta.Filename {
return nil, nil
}
data, err := c.mgr.st.ArtifactContent(c.run.Owner, string(origin.ArtifactID))
if err != nil {
return nil, err
}
sum := sha256.Sum256(data)
if hex.EncodeToString(sum[:]) != origin.ContentHash {
return nil, nil
}
after, err := c.mgr.st.GetArtifact(c.run.Owner, string(origin.ArtifactID))
if err != nil || !after.UpdatedAt.Equal(a.UpdatedAt) {
return nil, err
}
full := strings.TrimSpace(matched.PlainText())
if full == "" {
return nil, nil
}
limit := c.prof.Context.MaxToolResultChars
if limit <= 0 {
limit = int(c.eff.GetNumber("context.maxToolResultChars", 20000))
}
if limit <= 0 || limit > 4000 {
limit = 4000
}
doc := core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID,
Source: "upload", Title: matched.Meta.Filename, Ref: string(origin.ArtifactID),
Chunk: materialExcerpt(full, q.Text, matched.Meta.Filename, c.attachments, origin.ArtifactID, c.eff, limit),
CreatedAt: a.CreatedAt}
if c.mgr.knowledgeResourceAllow != nil && !c.mgr.knowledgeResourceAllow(string(c.run.Owner), doc) {
return nil, nil
}
return []core.RetrievalHit{{Doc: doc, Score: 1, Kind: "fulltext"}}, nil
}
func materialExcerpt(full, query, filename string, docs []core.ParsedDocument, id core.ID, eff core.EffectiveSettings, limit int) string {
runes := []rune(full)
start := 0
matchedTerm := false
terms := strings.Fields(strings.TrimSpace(strings.Replace(query, filename, "", 1)))
for _, term := range terms {
if at := strings.Index(full, term); at >= 0 {
matchedTerm = true
start = len([]rune(full[:at])) - 80
if start < 0 {
start = 0
}
break
}
}
if !matchedTerm {
perDoc := int(eff.GetNumber(materialPerDocKey, materialPerDocDef))
if perDoc <= 0 {
perDoc = materialPerDocDef
}
left := materialInlineTotal
for _, d := range docs {
count := len([]rune(strings.TrimSpace(d.PlainText())))
provided := min(perDoc, left, count)
if d.Meta.Origin != nil && d.Meta.Origin.ArtifactID == id {
if provided < len(runes) {
start = provided
}
break
}
left -= provided
}
}
end := min(start+limit, len(runes))
return string(runes[start:end])
}
+133
View File
@@ -0,0 +1,133 @@
package runmgr
import (
"strings"
"testing"
core "agent-system-core"
)
func materialRetrievalContext(t *testing.T) *runCtx {
t.Helper()
c := checkpointEnvironment(t)
card, err := c.mgr.st.GetCard(c.run.Owner, c.task.cardID)
if err != nil || card.Task == nil || len(card.Task.Inputs) != 1 {
t.Fatalf("trusted material input missing: card=%+v err=%v", card, err)
}
input := card.Task.Inputs[0]
c.attachments = []core.ParsedDocument{{
Meta: core.DocMeta{Filename: "决定.txt", Parser: "builtin:txt", Origin: &core.DocumentOrigin{
ArtifactID: core.ID(input.ArtifactID), ContentHash: input.ContentHash, ParserRevision: input.ParserRevision,
}},
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "上线六月"}},
}}
return c
}
func TestMaterialAttachmentFallbackQuery(t *testing.T) {
c := materialRetrievalContext(t)
hits, err := c.Retrieve(core.RetrievalQuery{Text: "决定.txt 上线 日期"})
if err != nil || len(hits) != 1 {
t.Fatalf("current material was not recovered from a combined query: hits=%+v err=%v", hits, err)
}
hit := hits[0].Doc
if hit.Ref != string(c.attachments[0].Meta.Origin.ArtifactID) ||
hit.ProjectID != c.run.ProjectID || hit.Owner != c.run.Owner ||
hit.Title != "决定.txt" || !strings.Contains(hit.Chunk, "上线六月") {
t.Fatalf("fallback lost the current attachment identity or text: %+v", hit)
}
}
func TestMaterialAttachmentFallbackScope(t *testing.T) {
for _, tc := range []struct {
name string
change func(*runCtx)
}{
{"other-subagent", func(c *runCtx) { c.subAgent = "agent.sub.general" }},
{"other-owner", func(c *runCtx) { c.run.Owner = "another-owner" }},
{"other-project", func(c *runCtx) { c.run.ProjectID = "another-project" }},
{"duplicate-name", func(c *runCtx) {
other := c.attachments[0]
origin := *other.Meta.Origin
origin.ArtifactID = "art_other"
other.Meta.Origin = &origin
c.attachments = append(c.attachments, other)
}},
} {
t.Run(tc.name, func(t *testing.T) {
c := materialRetrievalContext(t)
before, err := c.Retrieve(core.RetrievalQuery{Text: "决定.txt 上线 日期"})
if err != nil || len(before) != 1 {
t.Fatalf("control material was unavailable before scope change: hits=%+v err=%v", before, err)
}
tc.change(c)
hits, err := c.Retrieve(core.RetrievalQuery{Text: "决定.txt 上线 日期"})
if len(hits) != 0 {
t.Fatalf("out-of-scope attachment leaked: hits=%+v err=%v", hits, err)
}
})
}
}
func TestMaterialAttachmentFallbackVersionDrift(t *testing.T) {
for _, tc := range []struct {
name string
change func(*testing.T, *runCtx)
}{
{"changed-content", func(t *testing.T, c *runCtx) {
t.Helper()
if err := c.mgr.st.UpdateArtifactContent(string(c.run.Owner), string(c.attachments[0].Meta.Origin.ArtifactID), []byte("上线七月")); err != nil {
t.Fatal(err)
}
}},
{"changed-parser-revision", func(_ *testing.T, c *runCtx) {
c.attachments[0].Meta.Origin.ParserRevision = "different-parser"
}},
} {
t.Run(tc.name, func(t *testing.T) {
c := materialRetrievalContext(t)
before, err := c.Retrieve(core.RetrievalQuery{Text: "决定.txt 上线 日期"})
if err != nil || len(before) != 1 {
t.Fatalf("control material was unavailable before version change: hits=%+v err=%v", before, err)
}
tc.change(t, c)
hits, err := c.Retrieve(core.RetrievalQuery{Text: "决定.txt 上线 日期"})
if len(hits) != 0 {
t.Fatalf("stale material was returned: hits=%+v err=%v", hits, err)
}
})
}
}
func TestMaterialAttachmentFallbackRespectsResourceAllow(t *testing.T) {
c := materialRetrievalContext(t)
c.mgr.SetKnowledgeResourceAllow(func(owner string, doc core.KnowledgeDoc) bool {
return owner == string(c.run.Owner) && doc.Ref == string(c.attachments[0].Meta.Origin.ArtifactID)
})
allowed, err := c.Retrieve(core.RetrievalQuery{Text: "决定.txt 上线 日期"})
if err != nil || len(allowed) != 1 {
t.Fatalf("authorized current material was unavailable: hits=%+v err=%v", allowed, err)
}
c.mgr.SetKnowledgeResourceAllow(func(string, core.KnowledgeDoc) bool { return false })
denied, err := c.Retrieve(core.RetrievalQuery{Text: "决定.txt 上线 日期"})
if err != nil || len(denied) != 0 {
t.Fatalf("resource policy was bypassed by fallback: hits=%+v err=%v", denied, err)
}
}
func TestMaterialExcerptKeepsMatchedBeginning(t *testing.T) {
full := "上线六月" + strings.Repeat("附", 20) + "后续结论"
docs := []core.ParsedDocument{{
Meta: core.DocMeta{Filename: "长文.txt", Origin: &core.DocumentOrigin{ArtifactID: "art_long"}},
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: full}},
}}
eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(4)}}
got := materialExcerpt(full, "长文.txt 上线", "长文.txt", docs, "art_long", eff, 10)
if want := "上线六月" + strings.Repeat("附", 6); got != want {
t.Fatalf("matched start lost or exceeded the result limit: got %q want %q", got, want)
}
got = materialExcerpt(full, "长文.txt", "长文.txt", docs, "art_long", eff, 10)
if want := strings.Repeat("附", 10); got != want {
t.Fatalf("filename-only retrieval did not start after the inline prefix: got %q want %q", got, want)
}
}
+9 -5
View File
@@ -982,7 +982,8 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
if rs, ok := c.mgr.retriever.(interface {
RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error)
}); ok {
return rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow)
hits, err := rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow)
return c.materialRetrievalResult(q, hits, err)
}
hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q)
if err != nil {
@@ -1002,19 +1003,22 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
if len(out) > q.TopK {
out = out[:q.TopK]
}
return out, nil
return c.materialRetrievalResult(q, out, nil)
}
if c.mgr.retriever != nil {
if c.mgr.sharedKnowledgeAllow != nil {
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
return rs.RetrieveShared(c.ctx, c.run.Owner, q, func(docOwner, projectID core.ID) bool {
hits, err := rs.RetrieveShared(c.ctx, c.run.Owner, q, func(docOwner, projectID core.ID) bool {
return c.mgr.sharedKnowledgeAllow(string(c.run.Owner), projectID)
})
return c.materialRetrievalResult(q, hits, err)
}
}
return c.mgr.retriever.Retrieve(c.ctx, c.run.Owner, q)
hits, err := c.mgr.retriever.Retrieve(c.ctx, c.run.Owner, q)
return c.materialRetrievalResult(q, hits, err)
}
return c.mgr.st.SearchKnowledge(c.run.Owner, q)
hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q)
return c.materialRetrievalResult(q, hits, err)
}
// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。