Merge branch 'sipeed:main' into fix/inbound-dedup-messageid
This commit is contained in:
commit
9161974e8e
36 changed files with 343 additions and 198 deletions
25
cmd/picoclaw/internal/onboard/helpers_test.go
Normal file
25
cmd/picoclaw/internal/onboard/helpers_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package onboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) {
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
|
||||||
|
if err := copyEmbeddedToTarget(targetDir); err != nil {
|
||||||
|
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentsPath := filepath.Join(targetDir, "AGENTS.md")
|
||||||
|
if _, err := os.Stat(agentsPath); err != nil {
|
||||||
|
t.Fatalf("expected %s to exist: %v", agentsPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
legacyPath := filepath.Join(targetDir, "AGENT.md")
|
||||||
|
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -249,11 +250,9 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check tracked source files (bootstrap + memory).
|
// Check tracked source files (bootstrap + memory).
|
||||||
for _, p := range cb.sourcePaths() {
|
if slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) {
|
||||||
if cb.fileChangedSince(p) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- Skills directory (handled separately from sourcePaths) ---
|
// --- Skills directory (handled separately from sourcePaths) ---
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -404,11 +404,11 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errs := make(chan string, goroutines*iterations)
|
errs := make(chan string, goroutines*iterations)
|
||||||
|
|
||||||
for g := 0; g < goroutines; g++ {
|
for g := range goroutines {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(id int) {
|
go func(id int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := 0; i < iterations; i++ {
|
for i := range iterations {
|
||||||
result := cb.BuildSystemPromptWithCache()
|
result := cb.BuildSystemPromptWithCache()
|
||||||
if result == "" {
|
if result == "" {
|
||||||
errs <- "empty prompt returned"
|
errs <- "empty prompt returned"
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ func registerSharedTools(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web tools
|
// Web tools
|
||||||
if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
|
|
@ -113,10 +113,18 @@ func registerSharedTools(
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
Proxy: cfg.Tools.Web.Proxy,
|
Proxy: cfg.Tools.Web.Proxy,
|
||||||
}); searchTool != nil {
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()})
|
||||||
|
} else if searchTool != nil {
|
||||||
agent.Tools.Register(searchTool)
|
agent.Tools.Register(searchTool)
|
||||||
}
|
}
|
||||||
agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy))
|
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
|
} else {
|
||||||
|
agent.Tools.Register(fetchTool)
|
||||||
|
}
|
||||||
|
|
||||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
agent.Tools.Register(tools.NewI2CTool())
|
agent.Tools.Register(tools.NewI2CTool())
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -187,13 +188,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
toolsList := toolsInfo["names"].([]string)
|
toolsList := toolsInfo["names"].([]string)
|
||||||
|
|
||||||
// Check that our custom tool name is in the list
|
// Check that our custom tool name is in the list
|
||||||
found := false
|
found := slices.Contains(toolsList, "mock_custom")
|
||||||
for _, name := range toolsList {
|
|
||||||
if name == "mock_custom" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Error("Expected custom tool to be registered")
|
t.Error("Expected custom tool to be registered")
|
||||||
}
|
}
|
||||||
|
|
@ -262,13 +257,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
|
||||||
toolsList := toolsInfo["names"].([]string)
|
toolsList := toolsInfo["names"].([]string)
|
||||||
|
|
||||||
// Check that our custom tool name is in the list
|
// Check that our custom tool name is in the list
|
||||||
found := false
|
found := slices.Contains(toolsList, "mock_custom")
|
||||||
for _, name := range toolsList {
|
|
||||||
if name == "mock_custom" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Error("Expected custom tool to be registered")
|
t.Error("Expected custom tool to be registered")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
first := true
|
first := true
|
||||||
|
|
||||||
for i := 0; i < days; i++ {
|
for i := range days {
|
||||||
date := time.Now().AddDate(0, 0, -i)
|
date := time.Now().AddDate(0, 0, -i)
|
||||||
dateStr := date.Format("20060102") // YYYYMMDD
|
dateStr := date.Format("20060102") // YYYYMMDD
|
||||||
monthDir := dateStr[:6] // YYYYMM
|
monthDir := dateStr[:6] // YYYYMM
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ func TestPublishInbound_ContextCancel(t *testing.T) {
|
||||||
|
|
||||||
// Fill the buffer
|
// Fill the buffer
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
for i := 0; i < defaultBusBufferSize; i++ {
|
for i := range defaultBusBufferSize {
|
||||||
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||||
t.Fatalf("fill failed at %d: %v", i, err)
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +154,7 @@ func TestConcurrentPublishClose(t *testing.T) {
|
||||||
wg.Add(numGoroutines + 1)
|
wg.Add(numGoroutines + 1)
|
||||||
|
|
||||||
// Spawn many goroutines trying to publish
|
// Spawn many goroutines trying to publish
|
||||||
for i := 0; i < numGoroutines; i++ {
|
for range numGoroutines {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
// Use a short timeout context so we don't block forever after close
|
// Use a short timeout context so we don't block forever after close
|
||||||
|
|
@ -194,7 +194,7 @@ func TestPublishInbound_FullBuffer(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Fill the buffer
|
// Fill the buffer
|
||||||
for i := 0; i < defaultBusBufferSize; i++ {
|
for i := range defaultBusBufferSize {
|
||||||
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||||
t.Fatalf("fill failed at %d: %v", i, err)
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ type replyTokenEntry struct {
|
||||||
type LINEChannel struct {
|
type LINEChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config config.LINEConfig
|
config config.LINEConfig
|
||||||
|
infoClient *http.Client // for bot info lookups (short timeout)
|
||||||
|
apiClient *http.Client // for messaging API calls
|
||||||
botUserID string // Bot's user ID
|
botUserID string // Bot's user ID
|
||||||
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
||||||
botDisplayName string // Bot's display name for text-based mention detection
|
botDisplayName string // Bot's display name for text-based mention detection
|
||||||
|
|
@ -69,6 +71,8 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
|
||||||
return &LINEChannel{
|
return &LINEChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
infoClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
apiClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,8 +108,7 @@ func (c *LINEChannel) fetchBotInfo() error {
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
resp, err := c.infoClient.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -644,8 +647,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
resp, err := c.apiClient.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return channels.ClassifyNetError(err)
|
return channels.ClassifyNetError(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -274,13 +274,12 @@ func TestWorkerRateLimiter(t *testing.T) {
|
||||||
limiter: rate.NewLimiter(2, 1),
|
limiter: rate.NewLimiter(2, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx := t.Context()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
go m.runWorker(ctx, "test", w)
|
go m.runWorker(ctx, "test", w)
|
||||||
|
|
||||||
// Enqueue 4 messages
|
// Enqueue 4 messages
|
||||||
for i := 0; i < 4; i++ {
|
for i := range 4 {
|
||||||
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)}
|
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,8 +351,7 @@ func TestRunWorker_MessageSplitting(t *testing.T) {
|
||||||
limiter: rate.NewLimiter(rate.Inf, 1),
|
limiter: rate.NewLimiter(rate.Inf, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx := t.Context()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
go m.runWorker(ctx, "test", w)
|
go m.runWorker(ctx, "test", w)
|
||||||
|
|
||||||
|
|
@ -576,7 +574,7 @@ func TestRecordPlaceholder_ConcurrentSafe(t *testing.T) {
|
||||||
m := newTestManager()
|
m := newTestManager()
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i := 0; i < 100; i++ {
|
for i := range 100 {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(i int) {
|
go func(i int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
@ -591,7 +589,7 @@ func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
|
||||||
m := newTestManager()
|
m := newTestManager()
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i := 0; i < 100; i++ {
|
for i := range 100 {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(i int) {
|
go func(i int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
@ -834,7 +832,7 @@ func TestLazyWorkerCreation(t *testing.T) {
|
||||||
func TestBuildMediaScope_FastIDUniqueness(t *testing.T) {
|
func TestBuildMediaScope_FastIDUniqueness(t *testing.T) {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
for i := 0; i < 1000; i++ {
|
for range 1000 {
|
||||||
scope := BuildMediaScope("test", "chat1", "")
|
scope := BuildMediaScope("test", "chat1", "")
|
||||||
if seen[scope] {
|
if seen[scope] {
|
||||||
t.Fatalf("duplicate scope generated: %s", scope)
|
t.Fatalf("duplicate scope generated: %s", scope)
|
||||||
|
|
|
||||||
|
|
@ -337,10 +337,7 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OneBotChannel) reconnectLoop() {
|
func (c *OneBotChannel) reconnectLoop() {
|
||||||
interval := time.Duration(c.config.ReconnectInterval) * time.Second
|
interval := max(time.Duration(c.config.ReconnectInterval)*time.Second, 5*time.Second)
|
||||||
if interval < 5*time.Second {
|
|
||||||
interval = 5 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|
|
||||||
|
|
@ -292,8 +292,8 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||||
|
|
||||||
// Check Authorization header
|
// Check Authorization header
|
||||||
auth := r.Header.Get("Authorization")
|
auth := r.Header.Get("Authorization")
|
||||||
if strings.HasPrefix(auth, "Bearer ") {
|
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||||
if strings.TrimPrefix(auth, "Bearer ") == token {
|
if after == token {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,7 @@ func SplitMessage(content string, maxLen int) []string {
|
||||||
var messages []string
|
var messages []string
|
||||||
|
|
||||||
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
|
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
|
||||||
codeBlockBuffer := maxLen / 10
|
codeBlockBuffer := max(maxLen/10, 50)
|
||||||
if codeBlockBuffer < 50 {
|
|
||||||
codeBlockBuffer = 50
|
|
||||||
}
|
|
||||||
if codeBlockBuffer > maxLen/2 {
|
if codeBlockBuffer > maxLen/2 {
|
||||||
codeBlockBuffer = maxLen / 2
|
codeBlockBuffer = maxLen / 2
|
||||||
}
|
}
|
||||||
|
|
@ -40,10 +37,7 @@ func SplitMessage(content string, maxLen int) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effective split point: maxLen minus buffer, to leave room for code blocks
|
// Effective split point: maxLen minus buffer, to leave room for code blocks
|
||||||
effectiveLimit := maxLen - codeBlockBuffer
|
effectiveLimit := max(maxLen-codeBlockBuffer, maxLen/2)
|
||||||
if effectiveLimit < maxLen/2 {
|
|
||||||
effectiveLimit = maxLen / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
end := start + effectiveLimit
|
end := start + effectiveLimit
|
||||||
|
|
||||||
|
|
@ -85,10 +79,9 @@ func SplitMessage(content string, maxLen int) []string {
|
||||||
// If we have a reasonable amount of content after the header, split inside
|
// If we have a reasonable amount of content after the header, split inside
|
||||||
if msgEnd > headerEndIdx+20 {
|
if msgEnd > headerEndIdx+20 {
|
||||||
// Find a better split point closer to maxLen
|
// Find a better split point closer to maxLen
|
||||||
innerLimit := start + maxLen - 5 // Leave room for "\n```"
|
innerLimit := min(
|
||||||
if innerLimit > totalLen {
|
// Leave room for "\n```"
|
||||||
innerLimit = totalLen
|
start+maxLen-5, totalLen)
|
||||||
}
|
|
||||||
betterEnd := findLastNewlineInRange(runes, start, innerLimit, 200)
|
betterEnd := findLastNewlineInRange(runes, start, innerLimit, 200)
|
||||||
if betterEnd > headerEndIdx {
|
if betterEnd > headerEndIdx {
|
||||||
msgEnd = betterEnd
|
msgEnd = betterEnd
|
||||||
|
|
@ -117,10 +110,7 @@ func SplitMessage(content string, maxLen int) []string {
|
||||||
if unclosedIdx-start > 20 {
|
if unclosedIdx-start > 20 {
|
||||||
msgEnd = unclosedIdx
|
msgEnd = unclosedIdx
|
||||||
} else {
|
} else {
|
||||||
splitAt := start + maxLen - 5
|
splitAt := min(start+maxLen-5, totalLen)
|
||||||
if splitAt > totalLen {
|
|
||||||
splitAt = totalLen
|
|
||||||
}
|
|
||||||
chunk := strings.TrimRight(string(runes[start:splitAt]), " \t\n\r") + "\n```"
|
chunk := strings.TrimRight(string(runes[start:splitAt]), " \t\n\r") + "\n```"
|
||||||
messages = append(messages, chunk)
|
messages = append(messages, chunk)
|
||||||
remaining := strings.TrimSpace(header + "\n" + string(runes[splitAt:totalLen]))
|
remaining := strings.TrimSpace(header + "\n" + string(runes[splitAt:totalLen]))
|
||||||
|
|
@ -196,10 +186,7 @@ func findNewlineFrom(runes []rune, from int) int {
|
||||||
// findLastNewlineInRange finds the last newline within the last searchWindow runes
|
// findLastNewlineInRange finds the last newline within the last searchWindow runes
|
||||||
// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
|
// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
|
||||||
func findLastNewlineInRange(runes []rune, start, end, searchWindow int) int {
|
func findLastNewlineInRange(runes []rune, start, end, searchWindow int) int {
|
||||||
searchStart := end - searchWindow
|
searchStart := max(end-searchWindow, start)
|
||||||
if searchStart < start {
|
|
||||||
searchStart = start
|
|
||||||
}
|
|
||||||
for i := end - 1; i >= searchStart; i-- {
|
for i := end - 1; i >= searchStart; i-- {
|
||||||
if runes[i] == '\n' {
|
if runes[i] == '\n' {
|
||||||
return i
|
return i
|
||||||
|
|
@ -211,10 +198,7 @@ func findLastNewlineInRange(runes []rune, start, end, searchWindow int) int {
|
||||||
// findLastSpaceInRange finds the last space/tab within the last searchWindow runes
|
// findLastSpaceInRange finds the last space/tab within the last searchWindow runes
|
||||||
// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
|
// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
|
||||||
func findLastSpaceInRange(runes []rune, start, end, searchWindow int) int {
|
func findLastSpaceInRange(runes []rune, start, end, searchWindow int) int {
|
||||||
searchStart := end - searchWindow
|
searchStart := max(end-searchWindow, start)
|
||||||
if searchStart < start {
|
|
||||||
searchStart = start
|
|
||||||
}
|
|
||||||
for i := end - 1; i >= searchStart; i-- {
|
for i := end - 1; i >= searchStart; i-- {
|
||||||
if runes[i] == ' ' || runes[i] == '\t' {
|
if runes[i] == ' ' || runes[i] == '\t' {
|
||||||
return i
|
return i
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ const (
|
||||||
type WeComAppChannel struct {
|
type WeComAppChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config config.WeComAppConfig
|
config config.WeComAppConfig
|
||||||
|
client *http.Client
|
||||||
accessToken string
|
accessToken string
|
||||||
tokenExpiry time.Time
|
tokenExpiry time.Time
|
||||||
tokenMu sync.RWMutex
|
tokenMu sync.RWMutex
|
||||||
|
|
@ -129,10 +130,18 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
|
||||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Client timeout must be >= the configured ReplyTimeout so the
|
||||||
|
// per-request context deadline is always the effective limit.
|
||||||
|
clientTimeout := 30 * time.Second
|
||||||
|
if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout {
|
||||||
|
clientTimeout = d
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &WeComAppChannel{
|
return &WeComAppChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
client: &http.Client{Timeout: clientTimeout},
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
processedMsgs: make(map[string]bool),
|
processedMsgs: make(map[string]bool),
|
||||||
|
|
@ -306,8 +315,7 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
resp, err := c.client.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", channels.ClassifyNetError(err)
|
return "", channels.ClassifyNetError(err)
|
||||||
}
|
}
|
||||||
|
|
@ -364,8 +372,7 @@ func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, use
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
resp, err := c.client.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return channels.ClassifyNetError(err)
|
return channels.ClassifyNetError(err)
|
||||||
}
|
}
|
||||||
|
|
@ -746,8 +753,7 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
resp, err := c.client.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return channels.ClassifyNetError(err)
|
return channels.ClassifyNetError(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ func encryptTestMessageApp(message, aesKey string) (string, error) {
|
||||||
|
|
||||||
// Prepare message: random(16) + msg_len(4) + msg + corp_id
|
// Prepare message: random(16) + msg_len(4) + msg + corp_id
|
||||||
random := make([]byte, 0, 16)
|
random := make([]byte, 0, 16)
|
||||||
for i := 0; i < 16; i++ {
|
for i := range 16 {
|
||||||
random = append(random, byte(i+1))
|
random = append(random, byte(i+1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import (
|
||||||
type WeComBotChannel struct {
|
type WeComBotChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config config.WeComConfig
|
config config.WeComConfig
|
||||||
|
client *http.Client
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
||||||
|
|
@ -93,10 +94,18 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
|
||||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Client timeout must be >= the configured ReplyTimeout so the
|
||||||
|
// per-request context deadline is always the effective limit.
|
||||||
|
clientTimeout := 30 * time.Second
|
||||||
|
if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout {
|
||||||
|
clientTimeout = d
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &WeComBotChannel{
|
return &WeComBotChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
client: &http.Client{Timeout: clientTimeout},
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
processedMsgs: make(map[string]bool),
|
processedMsgs: make(map[string]bool),
|
||||||
|
|
@ -450,8 +459,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
resp, err := c.client.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return channels.ClassifyNetError(err)
|
return channels.ClassifyNetError(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ func encryptTestMessage(message, aesKey string) (string, error) {
|
||||||
|
|
||||||
// Prepare message: random(16) + msg_len(4) + msg + receiveid
|
// Prepare message: random(16) + msg_len(4) + msg + receiveid
|
||||||
random := make([]byte, 0, 16)
|
random := make([]byte, 0, 16)
|
||||||
for i := 0; i < 16; i++ {
|
for i := range 16 {
|
||||||
random = append(random, byte(i))
|
random = append(random, byte(i))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ func pkcs7Unpad(data []byte) ([]byte, error) {
|
||||||
return nil, fmt.Errorf("padding size larger than data")
|
return nil, fmt.Errorf("padding size larger than data")
|
||||||
}
|
}
|
||||||
// Verify all padding bytes
|
// Verify all padding bytes
|
||||||
for i := 0; i < padding; i++ {
|
for i := range padding {
|
||||||
if data[len(data)-1-i] != byte(padding) {
|
if data[len(data)-1-i] != byte(padding) {
|
||||||
return nil, fmt.Errorf("invalid padding byte at position %d", i)
|
return nil, fmt.Errorf("invalid padding byte at position %d", i)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ func TestGetModelConfig_RoundRobin(t *testing.T) {
|
||||||
|
|
||||||
// Test round-robin distribution
|
// Test round-robin distribution
|
||||||
results := make(map[string]int)
|
results := make(map[string]int)
|
||||||
for i := 0; i < 30; i++ {
|
for range 30 {
|
||||||
result, err := cfg.GetModelConfig("lb-model")
|
result, err := cfg.GetModelConfig("lb-model")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetModelConfig() error = %v", err)
|
t.Fatalf("GetModelConfig() error = %v", err)
|
||||||
|
|
@ -94,17 +94,15 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
errors := make(chan error, goroutines*iterations)
|
errors := make(chan error, goroutines*iterations)
|
||||||
|
|
||||||
for i := 0; i < goroutines; i++ {
|
for range goroutines {
|
||||||
wg.Add(1)
|
wg.Go(func() {
|
||||||
go func() {
|
for range iterations {
|
||||||
defer wg.Done()
|
|
||||||
for j := 0; j < iterations; j++ {
|
|
||||||
_, err := cfg.GetModelConfig("concurrent-model")
|
_, err := cfg.GetModelConfig("concurrent-model")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors <- err
|
errors <- err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -122,9 +123,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
ready := s.ready
|
ready := s.ready
|
||||||
checks := make(map[string]Check)
|
checks := make(map[string]Check)
|
||||||
for k, v := range s.checks {
|
maps.Copy(checks, s.checks)
|
||||||
checks[k] = v
|
|
||||||
}
|
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
if !ready {
|
if !ready {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ func TestReleaseAll(t *testing.T) {
|
||||||
|
|
||||||
paths := make([]string, 3)
|
paths := make([]string, 3)
|
||||||
refs := make([]string, 3)
|
refs := make([]string, 3)
|
||||||
for i := 0; i < 3; i++ {
|
for i := range 3 {
|
||||||
paths[i] = createTempFile(t, dir, strings.Repeat("a", i+1)+".jpg")
|
paths[i] = createTempFile(t, dir, strings.Repeat("a", i+1)+".jpg")
|
||||||
var err error
|
var err error
|
||||||
refs[i], err = store.Store(paths[i], MediaMeta{Source: "test"}, "scope1")
|
refs[i], err = store.Store(paths[i], MediaMeta{Source: "test"}, "scope1")
|
||||||
|
|
@ -228,12 +228,12 @@ func TestConcurrentSafety(t *testing.T) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(goroutines)
|
wg.Add(goroutines)
|
||||||
|
|
||||||
for g := 0; g < goroutines; g++ {
|
for g := range goroutines {
|
||||||
go func(gIdx int) {
|
go func(gIdx int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
scope := strings.Repeat("s", gIdx+1)
|
scope := strings.Repeat("s", gIdx+1)
|
||||||
|
|
||||||
for i := 0; i < filesPerGoroutine; i++ {
|
for i := range filesPerGoroutine {
|
||||||
path := createTempFile(t, dir, strings.Repeat("f", gIdx*filesPerGoroutine+i+1)+".tmp")
|
path := createTempFile(t, dir, strings.Repeat("f", gIdx*filesPerGoroutine+i+1)+".tmp")
|
||||||
ref, err := store.Store(path, MediaMeta{Source: "test"}, scope)
|
ref, err := store.Store(path, MediaMeta{Source: "test"}, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -448,11 +448,11 @@ func TestConcurrentCleanupSafety(t *testing.T) {
|
||||||
wg.Add(workers * 4)
|
wg.Add(workers * 4)
|
||||||
|
|
||||||
// Store workers
|
// Store workers
|
||||||
for w := 0; w < workers; w++ {
|
for w := range workers {
|
||||||
go func(wIdx int) {
|
go func(wIdx int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
scope := fmt.Sprintf("scope-%d", wIdx)
|
scope := fmt.Sprintf("scope-%d", wIdx)
|
||||||
for i := 0; i < ops; i++ {
|
for i := range ops {
|
||||||
p := createTempFile(t, dir, fmt.Sprintf("w%d-f%d.tmp", wIdx, i))
|
p := createTempFile(t, dir, fmt.Sprintf("w%d-f%d.tmp", wIdx, i))
|
||||||
store.Store(p, MediaMeta{Source: "test"}, scope)
|
store.Store(p, MediaMeta{Source: "test"}, scope)
|
||||||
}
|
}
|
||||||
|
|
@ -460,30 +460,30 @@ func TestConcurrentCleanupSafety(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve workers
|
// Resolve workers
|
||||||
for w := 0; w < workers; w++ {
|
for range workers {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := 0; i < ops; i++ {
|
for range ops {
|
||||||
store.Resolve("media://nonexistent")
|
store.Resolve("media://nonexistent")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseAll workers
|
// ReleaseAll workers
|
||||||
for w := 0; w < workers; w++ {
|
for w := range workers {
|
||||||
go func(wIdx int) {
|
go func(wIdx int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := 0; i < ops; i++ {
|
for range ops {
|
||||||
store.ReleaseAll(fmt.Sprintf("scope-%d", wIdx))
|
store.ReleaseAll(fmt.Sprintf("scope-%d", wIdx))
|
||||||
}
|
}
|
||||||
}(w)
|
}(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanExpired workers
|
// CleanExpired workers
|
||||||
for w := 0; w < workers; w++ {
|
for range workers {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i := 0; i < ops; i++ {
|
for range ops {
|
||||||
store.CleanExpired()
|
store.CleanExpired()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -212,14 +212,14 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseResponse(resp *anthropic.Message) *LLMResponse {
|
func parseResponse(resp *anthropic.Message) *LLMResponse {
|
||||||
var content string
|
var content strings.Builder
|
||||||
var toolCalls []ToolCall
|
var toolCalls []ToolCall
|
||||||
|
|
||||||
for _, block := range resp.Content {
|
for _, block := range resp.Content {
|
||||||
switch block.Type {
|
switch block.Type {
|
||||||
case "text":
|
case "text":
|
||||||
tb := block.AsText()
|
tb := block.AsText()
|
||||||
content += tb.Text
|
content.WriteString(tb.Text)
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
tu := block.AsToolUse()
|
tu := block.AsToolUse()
|
||||||
var args map[string]any
|
var args map[string]any
|
||||||
|
|
@ -246,7 +246,7 @@ func parseResponse(resp *anthropic.Message) *LLMResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
return &LLMResponse{
|
return &LLMResponse{
|
||||||
Content: content,
|
Content: content.String(),
|
||||||
ToolCalls: toolCalls,
|
ToolCalls: toolCalls,
|
||||||
FinishReason: finishReason,
|
FinishReason: finishReason,
|
||||||
Usage: &UsageInfo{
|
Usage: &UsageInfo{
|
||||||
|
|
@ -264,8 +264,8 @@ func normalizeBaseURL(apiBase string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
base = strings.TrimRight(base, "/")
|
base = strings.TrimRight(base, "/")
|
||||||
if strings.HasSuffix(base, "/v1") {
|
if before, ok := strings.CutSuffix(base, "/v1"); ok {
|
||||||
base = strings.TrimSuffix(base, "/v1")
|
base = before
|
||||||
}
|
}
|
||||||
if base == "" {
|
if base == "" {
|
||||||
return defaultBaseURL
|
return defaultBaseURL
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,8 @@ func resolveCodexModel(model string) (string, string) {
|
||||||
return codexDefaultModel, "empty model"
|
return codexDefaultModel, "empty model"
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(m, "openai/") {
|
if after, ok := strings.CutPrefix(m, "openai/"); ok {
|
||||||
m = strings.TrimPrefix(m, "openai/")
|
m = after
|
||||||
} else if strings.Contains(m, "/") {
|
} else if strings.Contains(m, "/") {
|
||||||
return codexDefaultModel, "non-openai model namespace"
|
return codexDefaultModel, "non-openai model namespace"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ func TestCooldown_FailureWindowReset(t *testing.T) {
|
||||||
ct, current := newTestTracker(now)
|
ct, current := newTestTracker(now)
|
||||||
|
|
||||||
// 4 errors → 1h cooldown
|
// 4 errors → 1h cooldown
|
||||||
for i := 0; i < 4; i++ {
|
for range 4 {
|
||||||
ct.MarkFailure("openai", FailoverRateLimit)
|
ct.MarkFailure("openai", FailoverRateLimit)
|
||||||
*current = current.Add(2 * time.Second) // small advance between errors
|
*current = current.Add(2 * time.Second) // small advance between errors
|
||||||
}
|
}
|
||||||
|
|
@ -230,7 +230,7 @@ func TestCooldown_ConcurrentAccess(t *testing.T) {
|
||||||
ct := NewCooldownTracker()
|
ct := NewCooldownTracker()
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for i := 0; i < 100; i++ {
|
for range 100 {
|
||||||
wg.Add(3)
|
wg.Add(3)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
|
||||||
|
|
@ -312,8 +312,8 @@ func stripSystemParts(messages []Message) []openaiMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeModel(model, apiBase string) string {
|
func normalizeModel(model, apiBase string) string {
|
||||||
idx := strings.Index(model, "/")
|
before, after, ok := strings.Cut(model, "/")
|
||||||
if idx == -1 {
|
if !ok {
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -321,10 +321,10 @@ func normalizeModel(model, apiBase string) string {
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix := strings.ToLower(model[:idx])
|
prefix := strings.ToLower(before)
|
||||||
switch prefix {
|
switch prefix {
|
||||||
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
|
case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
|
||||||
return model[idx+1:]
|
return after
|
||||||
default:
|
default:
|
||||||
return model
|
return model
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package routing
|
package routing
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNormalizeAgentID_Empty(t *testing.T) {
|
func TestNormalizeAgentID_Empty(t *testing.T) {
|
||||||
if got := NormalizeAgentID(""); got != DefaultAgentID {
|
if got := NormalizeAgentID(""); got != DefaultAgentID {
|
||||||
|
|
@ -57,11 +60,11 @@ func TestNormalizeAgentID_AllInvalid(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeAgentID_TruncatesAt64(t *testing.T) {
|
func TestNormalizeAgentID_TruncatesAt64(t *testing.T) {
|
||||||
long := ""
|
var long strings.Builder
|
||||||
for i := 0; i < 100; i++ {
|
for range 100 {
|
||||||
long += "a"
|
long.WriteString("a")
|
||||||
}
|
}
|
||||||
got := NormalizeAgentID(long)
|
got := NormalizeAgentID(long.String())
|
||||||
if len(got) > MaxAgentIDLength {
|
if len(got) > MaxAgentIDLength {
|
||||||
t.Errorf("length = %d, want <= %d", len(got), MaxAgentIDLength)
|
t.Errorf("length = %d, want <= %d", len(got), MaxAgentIDLength)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
|
||||||
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||||
|
|
||||||
for _, line := range strings.Split(normalized, "\n") {
|
for line := range strings.SplitSeq(normalized, "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package skills
|
package skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -183,7 +183,7 @@ func buildTrigrams(s string) []uint32 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort and Deduplication
|
// Sort and Deduplication
|
||||||
sort.Slice(trigrams, func(i, j int) bool { return trigrams[i] < trigrams[j] })
|
slices.Sort(trigrams)
|
||||||
n := 1
|
n := 1
|
||||||
for i := 1; i < len(trigrams); i++ {
|
for i := 1; i < len(trigrams); i++ {
|
||||||
if trigrams[i] != trigrams[i-1] {
|
if trigrams[i] != trigrams[i-1] {
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ func TestSearchCacheConcurrency(t *testing.T) {
|
||||||
|
|
||||||
// Concurrent writes
|
// Concurrent writes
|
||||||
go func() {
|
go func() {
|
||||||
for i := 0; i < 100; i++ {
|
for i := range 100 {
|
||||||
cache.Put("query-write-"+string(rune('a'+i%26)), []SearchResult{{Slug: "x"}})
|
cache.Put("query-write-"+string(rune('a'+i%26)), []SearchResult{{Slug: "x"}})
|
||||||
}
|
}
|
||||||
done <- struct{}{}
|
done <- struct{}{}
|
||||||
|
|
@ -161,7 +161,7 @@ func TestSearchCacheConcurrency(t *testing.T) {
|
||||||
|
|
||||||
// Concurrent reads
|
// Concurrent reads
|
||||||
go func() {
|
go func() {
|
||||||
for i := 0; i < 100; i++ {
|
for range 100 {
|
||||||
cache.Get("query-write-a")
|
cache.Get("query-write-a")
|
||||||
}
|
}
|
||||||
done <- struct{}{}
|
done <- struct{}{}
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ func TestConcurrentAccess(t *testing.T) {
|
||||||
|
|
||||||
// Test concurrent writes
|
// Test concurrent writes
|
||||||
done := make(chan bool, 10)
|
done := make(chan bool, 10)
|
||||||
for i := 0; i < 10; i++ {
|
for i := range 10 {
|
||||||
go func(idx int) {
|
go func(idx int) {
|
||||||
channel := fmt.Sprintf("channel-%d", idx)
|
channel := fmt.Sprintf("channel-%d", idx)
|
||||||
sm.SetLastChannel(channel)
|
sm.SetLastChannel(channel)
|
||||||
|
|
@ -144,7 +144,7 @@ func TestConcurrentAccess(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all goroutines to complete
|
// Wait for all goroutines to complete
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package tools
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -222,7 +223,8 @@ func (t *CronTool) listJobs() *ToolResult {
|
||||||
return SilentResult("No scheduled jobs")
|
return SilentResult("No scheduled jobs")
|
||||||
}
|
}
|
||||||
|
|
||||||
result := "Scheduled jobs:\n"
|
var result strings.Builder
|
||||||
|
result.WriteString("Scheduled jobs:\n")
|
||||||
for _, j := range jobs {
|
for _, j := range jobs {
|
||||||
var scheduleInfo string
|
var scheduleInfo string
|
||||||
if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil {
|
if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil {
|
||||||
|
|
@ -234,10 +236,10 @@ func (t *CronTool) listJobs() *ToolResult {
|
||||||
} else {
|
} else {
|
||||||
scheduleInfo = "unknown"
|
scheduleInfo = "unknown"
|
||||||
}
|
}
|
||||||
result += fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)
|
result.WriteString(fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo))
|
||||||
}
|
}
|
||||||
|
|
||||||
return SilentResult(result)
|
return SilentResult(result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTool) removeJob(args map[string]any) *ToolResult {
|
func (t *CronTool) removeJob(args map[string]any) *ToolResult {
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ func TestToolRegistry_ConcurrentAccess(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewToolRegistry()
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for i := 0; i < 50; i++ {
|
for i := range 50 {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(n int) {
|
go func(n int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
|
||||||
115
pkg/tools/web.go
115
pkg/tools/web.go
|
|
@ -15,6 +15,14 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
// HTTP client timeouts for web tool providers.
|
||||||
|
searchTimeout = 10 * time.Second // Brave, Tavily, DuckDuckGo
|
||||||
|
perplexityTimeout = 30 * time.Second // Perplexity (LLM-based, slower)
|
||||||
|
fetchTimeout = 60 * time.Second // WebFetchTool
|
||||||
|
|
||||||
|
defaultMaxChars = 50000
|
||||||
|
maxRedirects = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pre-compiled regexes for HTML text extraction
|
// Pre-compiled regexes for HTML text extraction
|
||||||
|
|
@ -74,6 +82,7 @@ type SearchProvider interface {
|
||||||
type BraveSearchProvider struct {
|
type BraveSearchProvider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
proxy string
|
proxy string
|
||||||
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
|
|
@ -88,11 +97,7 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("X-Subscription-Token", p.apiKey)
|
req.Header.Set("X-Subscription-Token", p.apiKey)
|
||||||
|
|
||||||
client, err := createHTTPClient(p.proxy, 10*time.Second)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
return "", fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -143,6 +148,7 @@ type TavilySearchProvider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
baseURL string
|
baseURL string
|
||||||
proxy string
|
proxy string
|
||||||
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
|
|
@ -174,11 +180,7 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
client, err := createHTTPClient(p.proxy, 10*time.Second)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
return "", fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -227,6 +229,7 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
||||||
|
|
||||||
type DuckDuckGoSearchProvider struct {
|
type DuckDuckGoSearchProvider struct {
|
||||||
proxy string
|
proxy string
|
||||||
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
|
|
@ -239,11 +242,7 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou
|
||||||
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
client, err := createHTTPClient(p.proxy, 10*time.Second)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
return "", fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +284,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
||||||
|
|
||||||
maxItems := min(len(matches), count)
|
maxItems := min(len(matches), count)
|
||||||
|
|
||||||
for i := 0; i < maxItems; i++ {
|
for i := range maxItems {
|
||||||
urlStr := matches[i][1]
|
urlStr := matches[i][1]
|
||||||
title := stripTags(matches[i][2])
|
title := stripTags(matches[i][2])
|
||||||
title = strings.TrimSpace(title)
|
title = strings.TrimSpace(title)
|
||||||
|
|
@ -293,9 +292,9 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
||||||
// URL decoding if needed
|
// URL decoding if needed
|
||||||
if strings.Contains(urlStr, "uddg=") {
|
if strings.Contains(urlStr, "uddg=") {
|
||||||
if u, err := url.QueryUnescape(urlStr); err == nil {
|
if u, err := url.QueryUnescape(urlStr); err == nil {
|
||||||
idx := strings.Index(u, "uddg=")
|
_, after, ok := strings.Cut(u, "uddg=")
|
||||||
if idx != -1 {
|
if ok {
|
||||||
urlStr = u[idx+5:]
|
urlStr = after
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -322,6 +321,7 @@ func stripTags(content string) string {
|
||||||
type PerplexitySearchProvider struct {
|
type PerplexitySearchProvider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
proxy string
|
proxy string
|
||||||
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
|
|
@ -356,11 +356,7 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
client, err := createHTTPClient(p.proxy, 30*time.Second)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
return "", fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -415,43 +411,60 @@ type WebSearchToolOptions struct {
|
||||||
Proxy string
|
Proxy string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
|
func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
var provider SearchProvider
|
var provider SearchProvider
|
||||||
maxResults := 5
|
maxResults := 5
|
||||||
|
|
||||||
// Priority: Perplexity > Brave > Tavily > DuckDuckGo
|
// Priority: Perplexity > Brave > Tavily > DuckDuckGo
|
||||||
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
|
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
|
||||||
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy}
|
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
|
||||||
|
}
|
||||||
|
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy, client: client}
|
||||||
if opts.PerplexityMaxResults > 0 {
|
if opts.PerplexityMaxResults > 0 {
|
||||||
maxResults = opts.PerplexityMaxResults
|
maxResults = opts.PerplexityMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.BraveEnabled && opts.BraveAPIKey != "" {
|
} else if opts.BraveEnabled && opts.BraveAPIKey != "" {
|
||||||
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy}
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
|
||||||
|
}
|
||||||
|
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy, client: client}
|
||||||
if opts.BraveMaxResults > 0 {
|
if opts.BraveMaxResults > 0 {
|
||||||
maxResults = opts.BraveMaxResults
|
maxResults = opts.BraveMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.TavilyEnabled && opts.TavilyAPIKey != "" {
|
} else if opts.TavilyEnabled && opts.TavilyAPIKey != "" {
|
||||||
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
|
||||||
|
}
|
||||||
provider = &TavilySearchProvider{
|
provider = &TavilySearchProvider{
|
||||||
apiKey: opts.TavilyAPIKey,
|
apiKey: opts.TavilyAPIKey,
|
||||||
baseURL: opts.TavilyBaseURL,
|
baseURL: opts.TavilyBaseURL,
|
||||||
proxy: opts.Proxy,
|
proxy: opts.Proxy,
|
||||||
|
client: client,
|
||||||
}
|
}
|
||||||
if opts.TavilyMaxResults > 0 {
|
if opts.TavilyMaxResults > 0 {
|
||||||
maxResults = opts.TavilyMaxResults
|
maxResults = opts.TavilyMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.DuckDuckGoEnabled {
|
} else if opts.DuckDuckGoEnabled {
|
||||||
provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy}
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err)
|
||||||
|
}
|
||||||
|
provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client}
|
||||||
if opts.DuckDuckGoMaxResults > 0 {
|
if opts.DuckDuckGoMaxResults > 0 {
|
||||||
maxResults = opts.DuckDuckGoMaxResults
|
maxResults = opts.DuckDuckGoMaxResults
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &WebSearchTool{
|
return &WebSearchTool{
|
||||||
provider: provider,
|
provider: provider,
|
||||||
maxResults: maxResults,
|
maxResults: maxResults,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WebSearchTool) Name() string {
|
func (t *WebSearchTool) Name() string {
|
||||||
|
|
@ -508,25 +521,34 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
||||||
type WebFetchTool struct {
|
type WebFetchTool struct {
|
||||||
maxChars int
|
maxChars int
|
||||||
proxy string
|
proxy string
|
||||||
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebFetchTool(maxChars int) *WebFetchTool {
|
func NewWebFetchTool(maxChars int) *WebFetchTool {
|
||||||
if maxChars <= 0 {
|
// createHTTPClient cannot fail with an empty proxy string.
|
||||||
maxChars = 50000
|
tool, _ := NewWebFetchToolWithProxy(maxChars, "")
|
||||||
}
|
return tool
|
||||||
return &WebFetchTool{
|
|
||||||
maxChars: maxChars,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebFetchToolWithProxy(maxChars int, proxy string) *WebFetchTool {
|
func NewWebFetchToolWithProxy(maxChars int, proxy string) (*WebFetchTool, error) {
|
||||||
if maxChars <= 0 {
|
if maxChars <= 0 {
|
||||||
maxChars = 50000
|
maxChars = defaultMaxChars
|
||||||
|
}
|
||||||
|
client, err := createHTTPClient(proxy, fetchTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
|
||||||
|
}
|
||||||
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= maxRedirects {
|
||||||
|
return fmt.Errorf("stopped after %d redirects", maxRedirects)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return &WebFetchTool{
|
return &WebFetchTool{
|
||||||
maxChars: maxChars,
|
maxChars: maxChars,
|
||||||
proxy: proxy,
|
proxy: proxy,
|
||||||
}
|
client: client,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *WebFetchTool) Name() string {
|
func (t *WebFetchTool) Name() string {
|
||||||
|
|
@ -588,20 +610,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
client, err := createHTTPClient(t.proxy, 60*time.Second)
|
resp, err := t.client.Do(req)
|
||||||
if err != nil {
|
|
||||||
return ErrorResult(fmt.Sprintf("failed to create HTTP client: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure redirect handling
|
|
||||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
||||||
if len(via) >= 5 {
|
|
||||||
return fmt.Errorf("stopped after 5 redirects")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("request failed: %v", err))
|
return ErrorResult(fmt.Sprintf("request failed: %v", err))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,13 +176,19 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
|
||||||
|
|
||||||
// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
|
// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
|
||||||
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""})
|
tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
if tool != nil {
|
if tool != nil {
|
||||||
t.Errorf("Expected nil tool when Brave API key is empty")
|
t.Errorf("Expected nil tool when Brave API key is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also nil when nothing is enabled
|
// Also nil when nothing is enabled
|
||||||
tool = NewWebSearchTool(WebSearchToolOptions{})
|
tool, err = NewWebSearchTool(WebSearchToolOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
if tool != nil {
|
if tool != nil {
|
||||||
t.Errorf("Expected nil tool when no provider is enabled")
|
t.Errorf("Expected nil tool when no provider is enabled")
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +196,10 @@ func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
||||||
|
|
||||||
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
|
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
|
||||||
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
|
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
|
tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{}
|
args := map[string]any{}
|
||||||
|
|
||||||
|
|
@ -438,7 +447,10 @@ func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewWebFetchToolWithProxy(t *testing.T) {
|
func TestNewWebFetchToolWithProxy(t *testing.T) {
|
||||||
tool := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890")
|
tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebFetchToolWithProxy() error: %v", err)
|
||||||
|
}
|
||||||
if tool.maxChars != 1024 {
|
if tool.maxChars != 1024 {
|
||||||
t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024)
|
t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024)
|
||||||
}
|
}
|
||||||
|
|
@ -446,7 +458,10 @@ func TestNewWebFetchToolWithProxy(t *testing.T) {
|
||||||
t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890")
|
t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890")
|
||||||
}
|
}
|
||||||
|
|
||||||
tool = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890")
|
tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebFetchToolWithProxy() error: %v", err)
|
||||||
|
}
|
||||||
if tool.maxChars != 50000 {
|
if tool.maxChars != 50000 {
|
||||||
t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000)
|
t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000)
|
||||||
}
|
}
|
||||||
|
|
@ -454,12 +469,15 @@ func TestNewWebFetchToolWithProxy(t *testing.T) {
|
||||||
|
|
||||||
func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
||||||
t.Run("perplexity", func(t *testing.T) {
|
t.Run("perplexity", func(t *testing.T) {
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
PerplexityEnabled: true,
|
PerplexityEnabled: true,
|
||||||
PerplexityAPIKey: "k",
|
PerplexityAPIKey: "k",
|
||||||
PerplexityMaxResults: 3,
|
PerplexityMaxResults: 3,
|
||||||
Proxy: "http://127.0.0.1:7890",
|
Proxy: "http://127.0.0.1:7890",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||||
|
}
|
||||||
p, ok := tool.provider.(*PerplexitySearchProvider)
|
p, ok := tool.provider.(*PerplexitySearchProvider)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider)
|
t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider)
|
||||||
|
|
@ -470,12 +488,15 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("brave", func(t *testing.T) {
|
t.Run("brave", func(t *testing.T) {
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
BraveEnabled: true,
|
BraveEnabled: true,
|
||||||
BraveAPIKey: "k",
|
BraveAPIKey: "k",
|
||||||
BraveMaxResults: 3,
|
BraveMaxResults: 3,
|
||||||
Proxy: "http://127.0.0.1:7890",
|
Proxy: "http://127.0.0.1:7890",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||||
|
}
|
||||||
p, ok := tool.provider.(*BraveSearchProvider)
|
p, ok := tool.provider.(*BraveSearchProvider)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider)
|
t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider)
|
||||||
|
|
@ -486,11 +507,14 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("duckduckgo", func(t *testing.T) {
|
t.Run("duckduckgo", func(t *testing.T) {
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
DuckDuckGoEnabled: true,
|
DuckDuckGoEnabled: true,
|
||||||
DuckDuckGoMaxResults: 3,
|
DuckDuckGoMaxResults: 3,
|
||||||
Proxy: "http://127.0.0.1:7890",
|
Proxy: "http://127.0.0.1:7890",
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||||
|
}
|
||||||
p, ok := tool.provider.(*DuckDuckGoSearchProvider)
|
p, ok := tool.provider.(*DuckDuckGoSearchProvider)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider)
|
t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider)
|
||||||
|
|
@ -542,12 +566,15 @@ func TestWebTool_TavilySearch_Success(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
TavilyEnabled: true,
|
TavilyEnabled: true,
|
||||||
TavilyAPIKey: "test-key",
|
TavilyAPIKey: "test-key",
|
||||||
TavilyBaseURL: server.URL,
|
TavilyBaseURL: server.URL,
|
||||||
TavilyMaxResults: 5,
|
TavilyMaxResults: 5,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
|
||||||
|
|
||||||
if i < maxRetries-1 {
|
if i < maxRetries-1 {
|
||||||
if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil {
|
if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil {
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("failed to sleep: %w", err)
|
return nil, fmt.Errorf("failed to sleep: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -77,6 +80,91 @@ func TestDoRequestWithRetry(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDoRequestWithRetry_ContextCancel(t *testing.T) {
|
||||||
|
// Use a long retry delay so cancellation always hits during sleepWithCtx.
|
||||||
|
retryDelayUnit = 10 * time.Second
|
||||||
|
t.Cleanup(func() { retryDelayUnit = time.Second })
|
||||||
|
|
||||||
|
bodyClosed := false
|
||||||
|
firstRoundTripDone := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := server.Client()
|
||||||
|
client.Timeout = 30 * time.Second
|
||||||
|
client.Transport = &bodyCloseTracker{
|
||||||
|
rt: client.Transport,
|
||||||
|
onClose: func() { bodyClosed = true },
|
||||||
|
// Signal after the first round-trip response is fully constructed on the client side.
|
||||||
|
onRoundTrip: func() {
|
||||||
|
select {
|
||||||
|
case firstRoundTripDone <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trackURL: server.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Cancel the context after the first round-trip completes on the client side.
|
||||||
|
// This ensures client.Do has returned a valid resp (with body) and the retry
|
||||||
|
// loop is about to enter sleepWithCtx, where the cancel will be detected.
|
||||||
|
go func() {
|
||||||
|
<-firstRoundTripDone
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := DoRequestWithRetry(client, req)
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
require.Error(t, err, "expected error from context cancellation")
|
||||||
|
assert.Nil(t, resp, "expected nil response when context is canceled")
|
||||||
|
assert.True(t, bodyClosed, "expected resp.Body to be closed on context cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyCloseTracker wraps an http.RoundTripper and records when response bodies are closed.
|
||||||
|
type bodyCloseTracker struct {
|
||||||
|
rt http.RoundTripper
|
||||||
|
onClose func()
|
||||||
|
onRoundTrip func() // called after each successful round-trip
|
||||||
|
trackURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *bodyCloseTracker) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := t.rt.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(req.URL.String(), t.trackURL) {
|
||||||
|
resp.Body = &closeNotifier{ReadCloser: resp.Body, onClose: t.onClose}
|
||||||
|
if t.onRoundTrip != nil {
|
||||||
|
t.onRoundTrip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeNotifier wraps an io.ReadCloser to detect Close calls.
|
||||||
|
type closeNotifier struct {
|
||||||
|
io.ReadCloser
|
||||||
|
onClose func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *closeNotifier) Close() error {
|
||||||
|
c.onClose()
|
||||||
|
return c.ReadCloser.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func TestDoRequestWithRetry_Delay(t *testing.T) {
|
func TestDoRequestWithRetry_Delay(t *testing.T) {
|
||||||
retryDelayUnit = time.Millisecond
|
retryDelayUnit = time.Millisecond
|
||||||
t.Cleanup(func() { retryDelayUnit = time.Second })
|
t.Cleanup(func() { retryDelayUnit = time.Second })
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue