446 lines
13 KiB
Go
446 lines
13 KiB
Go
package agents
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/mudler/cogito"
|
|
openai "github.com/sashabaranov/go-openai"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// mockLLM implements cogito.LLM and returns a fixed response.
|
|
type mockLLM struct {
|
|
response string
|
|
callCount atomic.Int32
|
|
}
|
|
|
|
func (m *mockLLM) Ask(ctx context.Context, f cogito.Fragment) (cogito.Fragment, error) {
|
|
m.callCount.Add(1)
|
|
return f.AddMessage(cogito.AssistantMessageRole, m.response), nil
|
|
}
|
|
|
|
func (m *mockLLM) CreateChatCompletion(ctx context.Context, req openai.ChatCompletionRequest) (cogito.LLMReply, cogito.LLMUsage, error) {
|
|
m.callCount.Add(1)
|
|
msg := openai.ChatCompletionMessage{
|
|
Role: "assistant",
|
|
Content: m.response,
|
|
}
|
|
return cogito.LLMReply{
|
|
ChatCompletionResponse: openai.ChatCompletionResponse{
|
|
Choices: []openai.ChatCompletionChoice{{Message: msg}},
|
|
},
|
|
}, 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
|
|
statuses []string
|
|
}
|
|
|
|
func (sc *statusCollector) onStatus(s string) {
|
|
sc.mu.Lock()
|
|
defer sc.mu.Unlock()
|
|
sc.statuses = append(sc.statuses, s)
|
|
}
|
|
|
|
func (sc *statusCollector) get() []string {
|
|
sc.mu.Lock()
|
|
defer sc.mu.Unlock()
|
|
cp := make([]string, len(sc.statuses))
|
|
copy(cp, sc.statuses)
|
|
return cp
|
|
}
|
|
|
|
var _ = DescribeTable("stripThinkingTags",
|
|
func(input, want string) {
|
|
Expect(stripThinkingTags(input)).To(Equal(want))
|
|
},
|
|
Entry("empty string", "", ""),
|
|
Entry("no tags", "Hello, world!", "Hello, world!"),
|
|
Entry("single tag pair", "before<thinking>secret thoughts</thinking>after", "beforeafter"),
|
|
Entry("multiple tag pairs", "a<thinking>one</thinking>b<thinking>two</thinking>c", "abc"),
|
|
Entry("nested tags", "<thinking>outer<thinking>inner</thinking>still outer</thinking>visible", "still outer</thinking>visible"),
|
|
Entry("unclosed opening tag", "hello<thinking>this is unclosed", "hello<thinking>this is unclosed"),
|
|
Entry("only closing tag", "hello</thinking>world", "hello</thinking>world"),
|
|
Entry("tags with whitespace around content", "before<thinking> spaced out </thinking>after", "beforeafter"),
|
|
Entry("empty thinking block", "before<thinking></thinking>after", "beforeafter"),
|
|
Entry("multiline thinking block", "before<thinking>\nline1\nline2\n</thinking>after", "beforeafter"),
|
|
Entry("adjacent tag pairs", "<thinking>a</thinking><thinking>b</thinking>", ""),
|
|
)
|
|
|
|
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
|
|
sc *statusCollector
|
|
cb Callbacks
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctx = context.Background()
|
|
sc = &statusCollector{}
|
|
cb = Callbacks{
|
|
OnStatus: sc.onStatus,
|
|
}
|
|
})
|
|
|
|
Context("basic chat completion", func() {
|
|
It("returns the LLM response", func() {
|
|
llm := &mockLLM{response: "Hello from the agent!"}
|
|
cfg := &AgentConfig{
|
|
Name: "test-agent",
|
|
Model: "test-model",
|
|
}
|
|
|
|
result, err := ExecuteChatWithLLM(ctx, llm, cfg, "Hi there", cb)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(Equal("Hello from the agent!"))
|
|
|
|
statuses := sc.get()
|
|
Expect(statuses).To(ContainElement("processing"))
|
|
Expect(statuses).To(ContainElement("completed"))
|
|
})
|
|
})
|
|
|
|
Context("empty model name", func() {
|
|
It("still succeeds because ExecuteChatWithLLM receives a pre-built LLM", func() {
|
|
// ExecuteChatWithLLM does not check cfg.Model — that's ExecuteChat's job.
|
|
// Verify it does not error with an empty model name.
|
|
llm := &mockLLM{response: "ok"}
|
|
cfg := &AgentConfig{
|
|
Name: "no-model-agent",
|
|
Model: "",
|
|
}
|
|
|
|
result, err := ExecuteChatWithLLM(ctx, llm, cfg, "test", cb)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(Equal("ok"))
|
|
})
|
|
})
|
|
|
|
Context("ExecuteChat rejects empty model", func() {
|
|
It("returns an error when model is empty", func() {
|
|
cfg := &AgentConfig{
|
|
Name: "empty-model",
|
|
Model: "",
|
|
}
|
|
|
|
_, err := ExecuteChat(ctx, "http://localhost", "key", cfg, "test", cb)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("no model configured"))
|
|
})
|
|
})
|
|
|
|
Context("StripThinkingTags flag", func() {
|
|
It("strips thinking tags from the response when enabled", func() {
|
|
llm := &mockLLM{response: "before<thinking>secret</thinking>after"}
|
|
cfg := &AgentConfig{
|
|
Name: "strip-agent",
|
|
Model: "test-model",
|
|
StripThinkingTags: true,
|
|
}
|
|
|
|
result, err := ExecuteChatWithLLM(ctx, llm, cfg, "test", cb)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(Equal("beforeafter"))
|
|
})
|
|
|
|
It("preserves thinking tags when flag is disabled", func() {
|
|
llm := &mockLLM{response: "before<thinking>secret</thinking>after"}
|
|
cfg := &AgentConfig{
|
|
Name: "no-strip-agent",
|
|
Model: "test-model",
|
|
StripThinkingTags: false,
|
|
}
|
|
|
|
result, err := ExecuteChatWithLLM(ctx, llm, cfg, "test", cb)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(result).To(Equal("before<thinking>secret</thinking>after"))
|
|
})
|
|
})
|
|
|
|
Context("OnMessage callback", func() {
|
|
It("delivers the agent response via OnMessage", func() {
|
|
var msgSender, msgContent string
|
|
cb.OnMessage = func(sender, content, messageID string) {
|
|
msgSender = sender
|
|
msgContent = content
|
|
}
|
|
|
|
llm := &mockLLM{response: "agent reply"}
|
|
cfg := &AgentConfig{
|
|
Name: "msg-agent",
|
|
Model: "test-model",
|
|
}
|
|
|
|
_, err := ExecuteChatWithLLM(ctx, llm, cfg, "hello", cb)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(msgSender).To(Equal("agent"))
|
|
Expect(msgContent).To(Equal("agent reply"))
|
|
})
|
|
})
|
|
|
|
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)
|
|
cancel() // immediately cancel
|
|
|
|
llm := &mockLLM{response: "should not reach"}
|
|
cfg := &AgentConfig{
|
|
Name: "cancel-agent",
|
|
Model: "test-model",
|
|
}
|
|
|
|
_, err := ExecuteChatWithLLM(cancelledCtx, llm, cfg, "test", cb)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("agent execution failed"))
|
|
})
|
|
})
|
|
})
|