diff --git a/Makefile b/Makefile index 9503bf7b4..cafcdd44a 100644 --- a/Makefile +++ b/Makefile @@ -180,7 +180,7 @@ osx-signed: build ## Run run: ## run local-ai - CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) run ./ + CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) run ./cmd/local-ai prepare-test: protogen-go build-mock-backend diff --git a/core/services/agentpool/agent_pool.go b/core/services/agentpool/agent_pool.go index 9216add9b..8744b8a35 100644 --- a/core/services/agentpool/agent_pool.go +++ b/core/services/agentpool/agent_pool.go @@ -466,10 +466,11 @@ func (s *AgentPoolService) Chat(name, message string) (string, error) { s.collectAndCopyMetadata(metadata, chatUserID) } + content := s.appendLocalAGIKBCitations(response.Response, name, message, response.State) msg := map[string]any{ "id": messageID + "-agent", "sender": "agent", - "content": response.Response, + "content": content, "timestamp": time.Now().Format(time.RFC3339), } if len(metadata) > 0 { @@ -489,6 +490,79 @@ func (s *AgentPoolService) Chat(name, message string) (string, error) { return messageID, nil } +func (s *AgentPoolService) appendLocalAGIKBCitations(response, agentKey, message string, states []coreTypes.ActionState) string { + if strings.TrimSpace(response) == "" { + return response + } + + userID, collection := splitAgentKey(agentKey) + cfg := s.localAGI.pool.GetConfig(agentKey) + if cfg == nil || !cfg.EnableKnowledgeBase { + return response + } + + citations := kbCitationsFromActionStates(states) + if len(citations) == 0 && cfg.KBAutoSearch { + maxResults := cfg.KnowledgeBaseResults + if maxResults <= 0 { + maxResults = 5 + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + kbResult := agents.KBAutoSearchPrompt(ctx, s.apiURL, s.apiKey, collection, message, maxResults, userID) + citations = kbResult.Citations + } + + return agents.AppendKBCitations(response, collection, userID, citations) +} + +func splitAgentKey(agentKey string) (userID, name string) { + if uid, n, ok := strings.Cut(agentKey, ":"); ok { + return uid, n + } + return "", agentKey +} + +func kbCitationsFromActionStates(states []coreTypes.ActionState) []agents.KBCitation { + var citations []agents.KBCitation + for _, state := range states { + citations = append(citations, kbCitationsFromMetadata(state.Metadata)...) + } + return citations +} + +func kbCitationsFromMetadata(metadata map[string]any) []agents.KBCitation { + if len(metadata) == 0 { + return nil + } + + fileName := metadata["file_name"] + source := metadata["source"] + if fileName == nil && source == nil { + return nil + } + + citation := agents.KBCitation{ + FileName: metadataString(fileName), + EntryKey: metadataString(source), + } + if citation.FileName == "" && citation.EntryKey == "" { + return nil + } + return []agents.KBCitation{citation} +} + +func metadataString(value any) string { + switch v := value.(type) { + case string: + return v + case fmt.Stringer: + return v.String() + default: + return "" + } +} + // userOutputsDir returns the per-user outputs directory, creating it if needed. // If userID is empty, falls back to the shared outputs directory. func (s *AgentPoolService) userOutputsDir(userID string) string { diff --git a/core/services/agents/citations.go b/core/services/agents/citations.go new file mode 100644 index 000000000..65c96d75c --- /dev/null +++ b/core/services/agents/citations.go @@ -0,0 +1,127 @@ +package agents + +import ( + "fmt" + "net/url" + "strings" + "sync" +) + +type kbCitationList struct { + mu sync.Mutex + citations []KBCitation +} + +func (l *kbCitationList) AddKBCitations(citations []KBCitation) { + if len(citations) == 0 { + return + } + l.mu.Lock() + defer l.mu.Unlock() + l.citations = append(l.citations, citations...) +} + +func (l *kbCitationList) Citations() []KBCitation { + l.mu.Lock() + defer l.mu.Unlock() + out := make([]KBCitation, len(l.citations)) + copy(out, l.citations) + return out +} + +// AppendKBCitations appends a markdown Sources block for KB citations. +func AppendKBCitations(response, collection, userID string, citations []KBCitation) string { + if strings.TrimSpace(response) == "" || len(citations) == 0 { + return response + } + + var lines []string + seen := make(map[string]struct{}) + for _, citation := range citations { + key := strings.TrimSpace(citation.EntryKey) + if key == "" { + key = strings.TrimSpace(citation.FileName) + } + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + displayName := kbCitationDisplayName(citation) + if displayName == "" { + continue + } + + sourceURL := kbCitationRawFileURL(collection, citation.EntryKey, userID) + number := len(lines) + 1 + if sourceURL == "" { + lines = append(lines, fmt.Sprintf("[%d] %s", number, displayName)) + continue + } + lines = append(lines, fmt.Sprintf("[%d] [%s](%s)", number, escapeMarkdownLinkText(displayName), sourceURL)) + } + + if len(lines) == 0 { + return response + } + + var sb strings.Builder + sb.WriteString(strings.TrimRight(response, "\n")) + sb.WriteString("\n\nSources:\n") + for _, line := range lines { + sb.WriteString(line) + sb.WriteString("\n") + } + return strings.TrimRight(sb.String(), "\n") +} + +func kbCitationDisplayName(citation KBCitation) string { + if fileName := strings.TrimSpace(citation.FileName); fileName != "" { + return fileName + } + + segments := strings.Split(strings.Trim(strings.TrimSpace(citation.EntryKey), "/"), "/") + for i := len(segments) - 1; i >= 0; i-- { + if segment := strings.TrimSpace(segments[i]); segment != "" { + return segment + } + } + return "" +} + +func kbCitationRawFileURL(collection, entryKey, userID string) string { + collection = strings.TrimSpace(collection) + entryKey = strings.Trim(strings.TrimSpace(entryKey), "/") + if collection == "" || entryKey == "" { + return "" + } + + var escapedEntrySegments []string + for _, segment := range strings.Split(entryKey, "/") { + if segment == "" { + continue + } + escapedEntrySegments = append(escapedEntrySegments, url.PathEscape(segment)) + } + if len(escapedEntrySegments) == 0 { + return "" + } + + sourceURL := "/api/agents/collections/" + url.PathEscape(collection) + "/entries-raw/" + strings.Join(escapedEntrySegments, "/") + if userID != "" { + query := url.Values{} + query.Set("user_id", userID) + sourceURL += "?" + query.Encode() + } + return sourceURL +} + +func escapeMarkdownLinkText(text string) string { + text = strings.ReplaceAll(text, `\`, `\\`) + text = strings.ReplaceAll(text, "[", `\[`) + text = strings.ReplaceAll(text, "]", `\]`) + return text +} diff --git a/core/services/agents/executor.go b/core/services/agents/executor.go index 81481600c..2787aeabc 100644 --- a/core/services/agents/executor.go +++ b/core/services/agents/executor.go @@ -167,10 +167,12 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m } } + kbCitations := &kbCitationList{} if cfg.EnableKnowledgeBase && (kbMode == KBModeAutoSearch || kbMode == KBModeBoth) { - kbResults := KBAutoSearchPrompt(ctx, effectiveURL, effectiveKey, cfg.Name, message, cfg.KnowledgeBaseResults, userID) - if kbResults != "" { - fragment = fragment.AddMessage(cogito.SystemMessageRole, kbResults) + kbResult := KBAutoSearchPrompt(ctx, effectiveURL, effectiveKey, cfg.Name, message, cfg.KnowledgeBaseResults, userID) + if kbResult.Prompt != "" { + fragment = fragment.AddMessage(cogito.SystemMessageRole, kbResult.Prompt) + kbCitations.AddKBCitations(kbResult.Citations) } } @@ -197,7 +199,7 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m } cogitoOpts = append(cogitoOpts, cogito.WithTools( cogito.NewToolDefinition( - KBSearchMemoryTool{APIURL: effectiveURL, APIKey: effectiveKey, Collection: cfg.Name, MaxResults: kbResults, UserID: userID}, + KBSearchMemoryTool{APIURL: effectiveURL, APIKey: effectiveKey, Collection: cfg.Name, MaxResults: kbResults, UserID: userID, CitationCollector: kbCitations}, KBSearchMemoryArgs{}, "search_memory", "Search the knowledge base for relevant information", @@ -336,6 +338,8 @@ func ExecuteChatWithLLM(ctx context.Context, llm cogito.LLM, cfg *AgentConfig, m if cfg.StripThinkingTags && response != "" { response = stripThinkingTags(response) } + responseForMemory := response + response = AppendKBCitations(response, cfg.Name, userID, kbCitations.Citations()) // Save conversation to KB when long-term memory is enabled. // 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 go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - saveConversationToKB(ctx, llm, effectiveURL, effectiveKey, cfg, message, response, userID) + saveConversationToKB(ctx, llm, effectiveURL, effectiveKey, cfg, message, responseForMemory, userID) }() } diff --git a/core/services/agents/executor_test.go b/core/services/agents/executor_test.go index 04d44462c..9b2001cca 100644 --- a/core/services/agents/executor_test.go +++ b/core/services/agents/executor_test.go @@ -2,6 +2,8 @@ package agents import ( "context" + "net/http" + "net/http/httptest" "sync" "sync/atomic" @@ -36,6 +38,34 @@ func (m *mockLLM) CreateChatCompletion(ctx context.Context, req openai.ChatCompl }, cogito.LLMUsage{}, nil } +type toolCallingMockLLM struct { + createResponses []openai.ChatCompletionResponse + askResponse string + callCount atomic.Int32 +} + +func (m *toolCallingMockLLM) Ask(ctx context.Context, f cogito.Fragment) (cogito.Fragment, error) { + m.callCount.Add(1) + return f.AddMessage(cogito.AssistantMessageRole, m.askResponse), nil +} + +func (m *toolCallingMockLLM) CreateChatCompletion(ctx context.Context, req openai.ChatCompletionRequest) (cogito.LLMReply, cogito.LLMUsage, error) { + idx := int(m.callCount.Add(1)) - 1 + if idx >= len(m.createResponses) { + return cogito.LLMReply{ + ChatCompletionResponse: openai.ChatCompletionResponse{ + Choices: []openai.ChatCompletionChoice{{ + Message: openai.ChatCompletionMessage{ + Role: "assistant", + Content: "No more tools needed.", + }, + }}, + }, + }, cogito.LLMUsage{}, nil + } + return cogito.LLMReply{ChatCompletionResponse: m.createResponses[idx]}, cogito.LLMUsage{}, nil +} + // statusCollector records status callbacks in a thread-safe way. type statusCollector struct { mu sync.Mutex @@ -73,6 +103,74 @@ var _ = DescribeTable("stripThinkingTags", Entry("adjacent tag pairs", "ab", ""), ) +var _ = DescribeTable("appendKBCitations", + func(response, collection, userID string, citations []KBCitation, want string) { + Expect(AppendKBCitations(response, collection, userID, citations)).To(Equal(want)) + }, + Entry("leaves responses without citations unchanged", + "answer", + "agent", + "", + nil, + "answer", + ), + Entry("leaves blank responses unchanged", + "", + "agent", + "", + []KBCitation{{FileName: "source.pdf", EntryKey: "uuid/source.pdf"}}, + "", + ), + Entry("appends clickable source links", + "answer", + "my-agent", + "", + []KBCitation{{FileName: "new feature.pdf", EntryKey: "uuid/new feature.pdf"}}, + "answer\n\nSources:\n[1] [new feature.pdf](/api/agents/collections/my-agent/entries-raw/uuid/new%20feature.pdf)", + ), + Entry("deduplicates citations by entry key", + "answer", + "agent", + "", + []KBCitation{ + {FileName: "first.pdf", EntryKey: "uuid/shared.pdf"}, + {FileName: "second.pdf", EntryKey: "uuid/shared.pdf"}, + }, + "answer\n\nSources:\n[1] [first.pdf](/api/agents/collections/agent/entries-raw/uuid/shared.pdf)", + ), + Entry("uses plain text when entry key is missing", + "answer", + "agent", + "", + []KBCitation{{FileName: "source.pdf"}}, + "answer\n\nSources:\n[1] source.pdf", + ), + Entry("uses entry basename when filename is missing", + "answer", + "agent", + "", + []KBCitation{{EntryKey: "uuid/source.pdf"}}, + "answer\n\nSources:\n[1] [source.pdf](/api/agents/collections/agent/entries-raw/uuid/source.pdf)", + ), + Entry("adds user id query when present", + "answer", + "agent", + "user 1", + []KBCitation{{FileName: "source.pdf", EntryKey: "uuid/source.pdf"}}, + "answer\n\nSources:\n[1] [source.pdf](/api/agents/collections/agent/entries-raw/uuid/source.pdf?user_id=user+1)", + ), + Entry("escapes collection, path segments, and markdown link text", + "answer", + "agent one", + "", + []KBCitation{{FileName: "source [draft].pdf", EntryKey: "uuid/source [draft].pdf"}}, + `answer + +Sources: +[1] [source \[draft\].pdf](/api/agents/collections/agent%20one/entries-raw/uuid/source%20%5Bdraft%5D.pdf)`, + ), +) + var _ = Describe("ExecuteChatWithLLM", func() { var ( ctx context.Context @@ -184,6 +282,150 @@ var _ = Describe("ExecuteChatWithLLM", func() { }) }) + Context("knowledge base citations", func() { + It("appends KB sources to the returned response and callback message", func() { + mux := http.NewServeMux() + mux.HandleFunc("/api/agents/collections/kb-agent/search", func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("user_id")).To(Equal("user-1")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "results": [ + { + "content": "KB content", + "id": "result-1", + "similarity": 0.99, + "metadata": { + "file_name": "new feature.pdf", + "source": "uuid/new feature.pdf" + } + } + ], + "count": 1 + }`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + var msgContent string + cb.OnMessage = func(sender, content, messageID string) { + msgContent = content + } + + llm := &mockLLM{response: "agent reply"} + cfg := &AgentConfig{ + Name: "kb-agent", + Model: "test-model", + EnableKnowledgeBase: true, + KBMode: KBModeAutoSearch, + } + + result, err := ExecuteChatWithLLM(ctx, llm, cfg, "hello", cb, ExecuteChatOpts{ + APIURL: server.URL, + UserID: "user-1", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal("agent reply\n\nSources:\n[1] [new feature.pdf](/api/agents/collections/kb-agent/entries-raw/uuid/new%20feature.pdf?user_id=user-1)")) + Expect(msgContent).To(Equal(result)) + }) + + It("collects citations from the search_memory tool", func() { + mux := http.NewServeMux() + mux.HandleFunc("/api/agents/collections/kb-agent/search", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "results": [ + { + "content": "Tool KB content", + "id": "result-1", + "similarity": 0.99, + "metadata": { + "file_name": "tool source.pdf", + "source": "uuid/tool source.pdf" + } + } + ], + "count": 1 + }`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + collector := &kbCitationList{} + tool := KBSearchMemoryTool{ + APIURL: server.URL, + Collection: "kb-agent", + CitationCollector: collector, + } + + result, _, err := tool.Run(KBSearchMemoryArgs{Query: "hello"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(ContainSubstring("Tool KB content")) + Expect(collector.Citations()).To(Equal([]KBCitation{{FileName: "tool source.pdf", EntryKey: "uuid/tool source.pdf"}})) + }) + + It("appends KB sources found through tools-only search_memory calls", func() { + mux := http.NewServeMux() + mux.HandleFunc("/api/agents/collections/kb-agent/search", func(w http.ResponseWriter, r *http.Request) { + Expect(r.URL.Query().Get("user_id")).To(Equal("user-1")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "results": [ + { + "content": "Tool KB content", + "id": "result-1", + "similarity": 0.99, + "metadata": { + "file_name": "tool source.pdf", + "source": "uuid/tool source.pdf" + } + } + ], + "count": 1 + }`)) + }) + server := httptest.NewServer(mux) + defer server.Close() + + llm := &toolCallingMockLLM{ + askResponse: "agent reply from tool context", + createResponses: []openai.ChatCompletionResponse{ + { + Choices: []openai.ChatCompletionChoice{ + { + Message: openai.ChatCompletionMessage{ + Role: "assistant", + ToolCalls: []openai.ToolCall{ + { + ID: "call-1", + Type: openai.ToolTypeFunction, + Function: openai.FunctionCall{ + Name: "search_memory", + Arguments: `{"query":"hello"}`, + }, + }, + }, + }, + }, + }, + }, + }, + } + cfg := &AgentConfig{ + Name: "kb-agent", + Model: "test-model", + EnableKnowledgeBase: true, + KBMode: KBModeTools, + } + + result, err := ExecuteChatWithLLM(ctx, llm, cfg, "hello", cb, ExecuteChatOpts{ + APIURL: server.URL, + UserID: "user-1", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal("agent reply from tool context\n\nSources:\n[1] [tool source.pdf](/api/agents/collections/kb-agent/entries-raw/uuid/tool%20source.pdf?user_id=user-1)")) + }) + }) + Context("context cancellation", func() { It("returns an error when context is already cancelled", func() { cancelledCtx, cancel := context.WithCancel(ctx) diff --git a/core/services/agents/knowledge.go b/core/services/agents/knowledge.go index 8c0afb012..ae87f560d 100644 --- a/core/services/agents/knowledge.go +++ b/core/services/agents/knowledge.go @@ -8,6 +8,7 @@ import ( "io" "mime/multipart" "net/http" + "net/url" "strings" "time" @@ -17,10 +18,19 @@ import ( "github.com/mudler/LocalAI/pkg/httpclient" ) +// Metadata keys populated by localrecall for every stored chunk. The original +// upload file name lives under file_name (used for display); source holds the +// collection entry key ("/") used to build the raw-file URL. +const ( + kbMetadataFileName = "file_name" + kbMetadataSource = "source" +) + // KBSearchResult represents a search result from the knowledge base. +// Field names mirror the collection search endpoint's JSON response. type KBSearchResult struct { Content string `json:"content"` - Score float64 `json:"score"` + ID string `json:"id"` Similarity float64 `json:"similarity"` Metadata map[string]string `json:"metadata"` } @@ -31,22 +41,48 @@ type kbSearchResponse struct { Count int `json:"count"` } -// KBAutoSearchPrompt queries the knowledge base with the user's message -// and returns a system prompt block with relevant results. +// KBCitation is a single source document that a KB search drew from. Citations +// travel alongside the prompt as structured data so the consumer (and UI) can +// render clickable source links, independent of what the model writes inline. +type KBCitation struct { + // FileName is the original uploaded file name, for display (e.g. "report.pdf"). + FileName string `json:"file_name"` + // EntryKey is the collection entry identifier ("/"), used to + // build the raw-file URL and as the de-duplication key. + EntryKey string `json:"entry_key"` +} + +// KBSearchContext is the result of an auto-search against the knowledge base: +// the system-prompt block to feed the model, plus the de-duplicated list of +// source documents the results were drawn from. +type KBSearchContext struct { + Prompt string `json:"prompt"` + Citations []KBCitation `json:"citations"` +} + +// KBCitationCollector receives source citations found during KB searches. +type KBCitationCollector interface { + AddKBCitations([]KBCitation) +} + +// KBAutoSearchPrompt queries the knowledge base with the user's message and +// returns a KBSearchContext: a system prompt block with the relevant results +// plus the de-duplicated source citations those results came from. // Uses LocalAI's collection search endpoint via the API. -func KBAutoSearchPrompt(ctx context.Context, apiURL, apiKey, collection, query string, maxResults int, userID string) string { +func KBAutoSearchPrompt(ctx context.Context, apiURL, apiKey, collection, query string, maxResults int, userID string) KBSearchContext { if collection == "" || query == "" { - return "" + return KBSearchContext{} } if maxResults <= 0 { maxResults = 5 } - // Call LocalAI's collection search API - searchURL := strings.TrimRight(apiURL, "/") + "/api/agents/collections/" + collection + "/search" + searchURL := strings.TrimRight(apiURL, "/") + "/api/agents/collections/" + url.PathEscape(collection) + "/search" if userID != "" { - searchURL += "?user_id=" + userID + query := url.Values{} + query.Set("user_id", userID) + searchURL += "?" + query.Encode() } reqBody, _ := json.Marshal(map[string]any{ "query": query, @@ -56,7 +92,7 @@ func KBAutoSearchPrompt(ctx context.Context, apiURL, apiKey, collection, query s req, err := http.NewRequestWithContext(ctx, http.MethodPost, searchURL, strings.NewReader(string(reqBody))) if err != nil { xlog.Warn("KB auto-search: failed to create request", "error", err) - return "" + return KBSearchContext{} } req.Header.Set("Content-Type", "application/json") if apiKey != "" { @@ -66,41 +102,70 @@ func KBAutoSearchPrompt(ctx context.Context, apiURL, apiKey, collection, query s resp, err := httpclient.New().Do(req) if err != nil { xlog.Warn("KB auto-search: request failed", "error", err) - return "" + return KBSearchContext{} } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) xlog.Warn("KB auto-search: non-200 response", "status", resp.StatusCode, "body", string(body)) - return "" + return KBSearchContext{} } var searchResp kbSearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { xlog.Warn("KB auto-search: failed to decode response", "error", err) - return "" + return KBSearchContext{} } if len(searchResp.Results) == 0 { - return "" + return KBSearchContext{} } - // Format results as a system prompt block (same format as LocalAGI) + // Build the system prompt block, labelling each chunk with its source file + // so the model can attribute inline, and collect the structured citations. var sb strings.Builder sb.WriteString("Given the user input you have the following in memory:\n") - for i, r := range searchResp.Results { - sb.WriteString(fmt.Sprintf("- %s", r.Content)) - if len(r.Metadata) > 0 { - meta, _ := json.Marshal(r.Metadata) - sb.WriteString(fmt.Sprintf(" (%s)", string(meta))) + + var citations []KBCitation + seen := make(map[string]struct{}) + + for _, r := range searchResp.Results { + fileName := r.Metadata[kbMetadataFileName] + source := r.Metadata[kbMetadataSource] + + label := fileName + if label == "" { + label = "unknown" } - if i < len(searchResp.Results)-1 { - sb.WriteString("\n") + sb.WriteString(fmt.Sprintf("[Source: %s]\n%s\n", label, r.Content)) + + // Citations are de-duplicated per source document: many chunks from the + // same file share one source key, so a file is listed only once. Skip + // results with no source key — they cannot be linked back to a document. + dedupKey := source + if dedupKey == "" { + dedupKey = fileName } + if dedupKey == "" { + continue + } + if _, ok := seen[dedupKey]; ok { + continue + } + seen[dedupKey] = struct{}{} + citations = append(citations, KBCitation{ + FileName: fileName, + EntryKey: source, + }) } - return sb.String() + sb.WriteString("When answering, cite sources using [Source: filename].") + + return KBSearchContext{ + Prompt: sb.String(), + Citations: citations, + } } // KBSearchMemoryArgs defines the arguments for the search_memory tool. @@ -110,21 +175,25 @@ type KBSearchMemoryArgs struct { // KBSearchMemoryTool implements the search_memory MCP tool. type KBSearchMemoryTool struct { - APIURL string - APIKey string - Collection string - MaxResults int - UserID string + APIURL string + APIKey string + Collection string + MaxResults int + UserID string + CitationCollector KBCitationCollector } func (t KBSearchMemoryTool) Run(args KBSearchMemoryArgs) (string, any, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() result := KBAutoSearchPrompt(ctx, t.APIURL, t.APIKey, t.Collection, args.Query, t.MaxResults, t.UserID) - if result == "" { + if result.Prompt == "" { return "No results found.", nil, nil } - return result, nil, nil + if t.CitationCollector != nil { + t.CitationCollector.AddKBCitations(result.Citations) + } + return result.Prompt, nil, nil } // KBAddMemoryArgs defines the arguments for the add_memory tool. @@ -156,9 +225,11 @@ func (t KBAddMemoryTool) Run(args KBAddMemoryArgs) (string, any, error) { // KBStoreContent uploads text content to a collection via the multipart upload API. func KBStoreContent(ctx context.Context, apiURL, apiKey, collection, content, userID string) error { - uploadURL := strings.TrimRight(apiURL, "/") + "/api/agents/collections/" + collection + "/upload" + uploadURL := strings.TrimRight(apiURL, "/") + "/api/agents/collections/" + url.PathEscape(collection) + "/upload" if userID != "" { - uploadURL += "?user_id=" + userID + query := url.Values{} + query.Set("user_id", userID) + uploadURL += "?" + query.Encode() } // Build multipart form with the text content as a file