Skip to content

Commit d2e6b93

Browse files
petechentwshihyunhuangTLoE419Oceankj
authored
feat(agents): surface KB source citations in RAG responses (#10228)
* dev knowledge.go structure Signed-off-by: Pete Chen <petechentw@gmail.com> * feat(agents): append KB source citations to responses Render structured KB citations as a Sources block after agent responses, linking each source to the existing raw collection entry endpoint. Keep long-term memory writes on the original model response so citation blocks do not get stored back into the knowledge base. Tested with: go test ./core/services/agents Assisted-by: Codex:gpt-5 Signed-off-by: Pete Chen <petechentw@gmail.com> * Collect KB citations from tool searches Signed-off-by: Pete Chen <petechentw@gmail.com> * fix(agents): append KB sources in local chats Apply the shared KB citation post-processing to standalone LocalAGI chat responses so the React agent chat receives the same clickable Sources block as the native executor path. Also fix the run target to use the current cmd/local-ai entrypoint. Assisted-by: Codex:gpt-5 Signed-off-by: Pete Chen <petechentw@gmail.com> --------- Signed-off-by: Pete Chen <petechentw@gmail.com> Co-authored-by: shihyunhuang <shihyunhuang88@gmail.com> Co-authored-by: TLoE419 <tloemizuchizu@gmail.com> Co-authored-by: Ching Kao <0980124jim@gmail.com>
1 parent e1ec03d commit d2e6b93

6 files changed

Lines changed: 556 additions & 38 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ osx-signed: build
180180

181181
## Run
182182
run: ## run local-ai
183-
CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) run ./
183+
CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) run ./cmd/local-ai
184184

185185
prepare-test: protogen-go build-mock-backend
186186

core/services/agentpool/agent_pool.go

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,10 +466,11 @@ func (s *AgentPoolService) Chat(name, message string) (string, error) {
466466
s.collectAndCopyMetadata(metadata, chatUserID)
467467
}
468468

469+
content := s.appendLocalAGIKBCitations(response.Response, name, message, response.State)
469470
msg := map[string]any{
470471
"id": messageID + "-agent",
471472
"sender": "agent",
472-
"content": response.Response,
473+
"content": content,
473474
"timestamp": time.Now().Format(time.RFC3339),
474475
}
475476
if len(metadata) > 0 {
@@ -489,6 +490,79 @@ func (s *AgentPoolService) Chat(name, message string) (string, error) {
489490
return messageID, nil
490491
}
491492

493+
func (s *AgentPoolService) appendLocalAGIKBCitations(response, agentKey, message string, states []coreTypes.ActionState) string {
494+
if strings.TrimSpace(response) == "" {
495+
return response
496+
}
497+
498+
userID, collection := splitAgentKey(agentKey)
499+
cfg := s.localAGI.pool.GetConfig(agentKey)
500+
if cfg == nil || !cfg.EnableKnowledgeBase {
501+
return response
502+
}
503+
504+
citations := kbCitationsFromActionStates(states)
505+
if len(citations) == 0 && cfg.KBAutoSearch {
506+
maxResults := cfg.KnowledgeBaseResults
507+
if maxResults <= 0 {
508+
maxResults = 5
509+
}
510+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
511+
defer cancel()
512+
kbResult := agents.KBAutoSearchPrompt(ctx, s.apiURL, s.apiKey, collection, message, maxResults, userID)
513+
citations = kbResult.Citations
514+
}
515+
516+
return agents.AppendKBCitations(response, collection, userID, citations)
517+
}
518+
519+
func splitAgentKey(agentKey string) (userID, name string) {
520+
if uid, n, ok := strings.Cut(agentKey, ":"); ok {
521+
return uid, n
522+
}
523+
return "", agentKey
524+
}
525+
526+
func kbCitationsFromActionStates(states []coreTypes.ActionState) []agents.KBCitation {
527+
var citations []agents.KBCitation
528+
for _, state := range states {
529+
citations = append(citations, kbCitationsFromMetadata(state.Metadata)...)
530+
}
531+
return citations
532+
}
533+
534+
func kbCitationsFromMetadata(metadata map[string]any) []agents.KBCitation {
535+
if len(metadata) == 0 {
536+
return nil
537+
}
538+
539+
fileName := metadata["file_name"]
540+
source := metadata["source"]
541+
if fileName == nil && source == nil {
542+
return nil
543+
}
544+
545+
citation := agents.KBCitation{
546+
FileName: metadataString(fileName),
547+
EntryKey: metadataString(source),
548+
}
549+
if citation.FileName == "" && citation.EntryKey == "" {
550+
return nil
551+
}
552+
return []agents.KBCitation{citation}
553+
}
554+
555+
func metadataString(value any) string {
556+
switch v := value.(type) {
557+
case string:
558+
return v
559+
case fmt.Stringer:
560+
return v.String()
561+
default:
562+
return ""
563+
}
564+
}
565+
492566
// userOutputsDir returns the per-user outputs directory, creating it if needed.
493567
// If userID is empty, falls back to the shared outputs directory.
494568
func (s *AgentPoolService) userOutputsDir(userID string) string {

core/services/agents/citations.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package agents
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"strings"
7+
"sync"
8+
)
9+
10+
type kbCitationList struct {
11+
mu sync.Mutex
12+
citations []KBCitation
13+
}
14+
15+
func (l *kbCitationList) AddKBCitations(citations []KBCitation) {
16+
if len(citations) == 0 {
17+
return
18+
}
19+
l.mu.Lock()
20+
defer l.mu.Unlock()
21+
l.citations = append(l.citations, citations...)
22+
}
23+
24+
func (l *kbCitationList) Citations() []KBCitation {
25+
l.mu.Lock()
26+
defer l.mu.Unlock()
27+
out := make([]KBCitation, len(l.citations))
28+
copy(out, l.citations)
29+
return out
30+
}
31+
32+
// AppendKBCitations appends a markdown Sources block for KB citations.
33+
func AppendKBCitations(response, collection, userID string, citations []KBCitation) string {
34+
if strings.TrimSpace(response) == "" || len(citations) == 0 {
35+
return response
36+
}
37+
38+
var lines []string
39+
seen := make(map[string]struct{})
40+
for _, citation := range citations {
41+
key := strings.TrimSpace(citation.EntryKey)
42+
if key == "" {
43+
key = strings.TrimSpace(citation.FileName)
44+
}
45+
if key == "" {
46+
continue
47+
}
48+
if _, ok := seen[key]; ok {
49+
continue
50+
}
51+
seen[key] = struct{}{}
52+
53+
displayName := kbCitationDisplayName(citation)
54+
if displayName == "" {
55+
continue
56+
}
57+
58+
sourceURL := kbCitationRawFileURL(collection, citation.EntryKey, userID)
59+
number := len(lines) + 1
60+
if sourceURL == "" {
61+
lines = append(lines, fmt.Sprintf("[%d] %s", number, displayName))
62+
continue
63+
}
64+
lines = append(lines, fmt.Sprintf("[%d] [%s](%s)", number, escapeMarkdownLinkText(displayName), sourceURL))
65+
}
66+
67+
if len(lines) == 0 {
68+
return response
69+
}
70+
71+
var sb strings.Builder
72+
sb.WriteString(strings.TrimRight(response, "\n"))
73+
sb.WriteString("\n\nSources:\n")
74+
for _, line := range lines {
75+
sb.WriteString(line)
76+
sb.WriteString("\n")
77+
}
78+
return strings.TrimRight(sb.String(), "\n")
79+
}
80+
81+
func kbCitationDisplayName(citation KBCitation) string {
82+
if fileName := strings.TrimSpace(citation.FileName); fileName != "" {
83+
return fileName
84+
}
85+
86+
segments := strings.Split(strings.Trim(strings.TrimSpace(citation.EntryKey), "/"), "/")
87+
for i := len(segments) - 1; i >= 0; i-- {
88+
if segment := strings.TrimSpace(segments[i]); segment != "" {
89+
return segment
90+
}
91+
}
92+
return ""
93+
}
94+
95+
func kbCitationRawFileURL(collection, entryKey, userID string) string {
96+
collection = strings.TrimSpace(collection)
97+
entryKey = strings.Trim(strings.TrimSpace(entryKey), "/")
98+
if collection == "" || entryKey == "" {
99+
return ""
100+
}
101+
102+
var escapedEntrySegments []string
103+
for _, segment := range strings.Split(entryKey, "/") {
104+
if segment == "" {
105+
continue
106+
}
107+
escapedEntrySegments = append(escapedEntrySegments, url.PathEscape(segment))
108+
}
109+
if len(escapedEntrySegments) == 0 {
110+
return ""
111+
}
112+
113+
sourceURL := "/api/agents/collections/" + url.PathEscape(collection) + "/entries-raw/" + strings.Join(escapedEntrySegments, "/")
114+
if userID != "" {
115+
query := url.Values{}
116+
query.Set("user_id", userID)
117+
sourceURL += "?" + query.Encode()
118+
}
119+
return sourceURL
120+
}
121+
122+
func escapeMarkdownLinkText(text string) string {
123+
text = strings.ReplaceAll(text, `\`, `\\`)
124+
text = strings.ReplaceAll(text, "[", `\[`)
125+
text = strings.ReplaceAll(text, "]", `\]`)
126+
return text
127+
}

core/services/agents/executor.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,12 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m
167167
}
168168
}
169169

170+
kbCitations := &kbCitationList{}
170171
if cfg.EnableKnowledgeBase && (kbMode == KBModeAutoSearch || kbMode == KBModeBoth) {
171-
kbResults := KBAutoSearchPrompt(ctx, effectiveURL, effectiveKey, cfg.Name, message, cfg.KnowledgeBaseResults, userID)
172-
if kbResults != "" {
173-
fragment = fragment.AddMessage(cogito.SystemMessageRole, kbResults)
172+
kbResult := KBAutoSearchPrompt(ctx, effectiveURL, effectiveKey, cfg.Name, message, cfg.KnowledgeBaseResults, userID)
173+
if kbResult.Prompt != "" {
174+
fragment = fragment.AddMessage(cogito.SystemMessageRole, kbResult.Prompt)
175+
kbCitations.AddKBCitations(kbResult.Citations)
174176
}
175177
}
176178

@@ -197,7 +199,7 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m
197199
}
198200
cogitoOpts = append(cogitoOpts, cogito.WithTools(
199201
cogito.NewToolDefinition(
200-
KBSearchMemoryTool{APIURL: effectiveURL, APIKey: effectiveKey, Collection: cfg.Name, MaxResults: kbResults, UserID: userID},
202+
KBSearchMemoryTool{APIURL: effectiveURL, APIKey: effectiveKey, Collection: cfg.Name, MaxResults: kbResults, UserID: userID, CitationCollector: kbCitations},
201203
KBSearchMemoryArgs{},
202204
"search_memory",
203205
"Search the knowledge base for relevant information",
@@ -336,6 +338,8 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m
336338
if cfg.StripThinkingTags && response != "" {
337339
response = stripThinkingTags(response)
338340
}
341+
responseForMemory := response
342+
response = AppendKBCitations(response, cfg.Name, userID, kbCitations.Citations())
339343

340344
// Save conversation to KB when long-term memory is enabled.
341345
// Use a detached context: the parent ctx may be cancelled (e.g. in distributed
@@ -344,7 +348,7 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m
344348
go func() {
345349
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
346350
defer cancel()
347-
saveConversationToKB(ctx, llm, effectiveURL, effectiveKey, cfg, message, response, userID)
351+
saveConversationToKB(ctx, llm, effectiveURL, effectiveKey, cfg, message, responseForMemory, userID)
348352
}()
349353
}
350354

0 commit comments

Comments
 (0)