Merge branch 'main' into max_tools
This commit is contained in:
commit
a49337efed
41 changed files with 1504 additions and 392 deletions
|
|
@ -18,10 +18,10 @@ builds:
|
||||||
- stdjson
|
- stdjson
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
|
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
|
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
|
|
@ -125,6 +125,23 @@ dockers_v2:
|
||||||
- linux/arm64
|
- linux/arm64
|
||||||
- linux/riscv64
|
- linux/riscv64
|
||||||
|
|
||||||
|
- id: picoclaw-launcher
|
||||||
|
dockerfile: docker/Dockerfile.goreleaser.launcher
|
||||||
|
ids:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
|
images:
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||||
|
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
|
||||||
|
tags:
|
||||||
|
- "{{ .Tag }}-launcher"
|
||||||
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
- linux/riscv64
|
||||||
|
|
||||||
notarize:
|
notarize:
|
||||||
macos:
|
macos:
|
||||||
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
|
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -11,8 +11,8 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||||
BUILD_TIME=$(shell date +%FT%T%z)
|
BUILD_TIME=$(shell date +%FT%T%z)
|
||||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||||
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
|
LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w"
|
||||||
|
|
||||||
# Go variables
|
# Go variables
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -194,6 +194,19 @@ docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
docker compose -f docker/docker-compose.yml --profile gateway down
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Launcher Mode (Web Console)
|
||||||
|
|
||||||
|
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The web console does not yet support authentication. Avoid exposing it to the public internet.
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
defer msgBus.Close()
|
defer msgBus.Close()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
defer agentLoop.Close()
|
||||||
|
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,42 @@
|
||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
|
var noTruncate bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
Aliases: []string{"g"},
|
Aliases: []string{"g"},
|
||||||
Short: "Start picoclaw gateway",
|
Short: "Start picoclaw gateway",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
|
PreRunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
if noTruncate && !debug {
|
||||||
|
return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if noTruncate {
|
||||||
|
utils.SetDisableTruncation(true)
|
||||||
|
logger.Info("String truncation is globally disabled via 'no-truncate' flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
return gatewayCmd(debug)
|
return gatewayCmd(debug)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
|
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,7 @@ func gatewayCmd(debug bool) error {
|
||||||
cronService.Stop()
|
cronService.Stop()
|
||||||
mediaStore.Stop()
|
mediaStore.Stop()
|
||||||
agentLoop.Stop()
|
agentLoop.Stop()
|
||||||
|
agentLoop.Close()
|
||||||
fmt.Println("✓ Gateway stopped")
|
fmt.Println("✓ Gateway stopped")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,14 @@
|
||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Logo = "🦞"
|
const Logo = "🦞"
|
||||||
|
|
||||||
var (
|
|
||||||
version = "dev"
|
|
||||||
gitCommit string
|
|
||||||
buildTime string
|
|
||||||
goVersion string
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetPicoclawHome returns the picoclaw home directory.
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
func GetPicoclawHome() string {
|
func GetPicoclawHome() string {
|
||||||
|
|
@ -40,25 +31,19 @@ func LoadConfig() (*config.Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatVersion returns the version string with optional git commit
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
// Deprecated: Use pkg/config.FormatVersion instead
|
||||||
func FormatVersion() string {
|
func FormatVersion() string {
|
||||||
v := version
|
return config.FormatVersion()
|
||||||
if gitCommit != "" {
|
|
||||||
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatBuildInfo returns build time and go version info
|
// FormatBuildInfo returns build time and go version info
|
||||||
|
// Deprecated: Use pkg/config.FormatBuildInfo instead
|
||||||
func FormatBuildInfo() (string, string) {
|
func FormatBuildInfo() (string, string) {
|
||||||
build := buildTime
|
return config.FormatBuildInfo()
|
||||||
goVer := goVersion
|
|
||||||
if goVer == "" {
|
|
||||||
goVer = runtime.Version()
|
|
||||||
}
|
|
||||||
return build, goVer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVersion returns the version string
|
// GetVersion returns the version string
|
||||||
|
// Deprecated: Use pkg/config.GetVersion instead
|
||||||
func GetVersion() string {
|
func GetVersion() string {
|
||||||
return version
|
return config.GetVersion()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,65 +40,6 @@ func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
|
||||||
oldVersion, oldGit := version, gitCommit
|
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
|
||||||
|
|
||||||
version = "1.2.3"
|
|
||||||
gitCommit = ""
|
|
||||||
|
|
||||||
assert.Equal(t, "1.2.3", FormatVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
|
||||||
oldVersion, oldGit := version, gitCommit
|
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
|
||||||
|
|
||||||
version = "1.2.3"
|
|
||||||
gitCommit = "abc123"
|
|
||||||
|
|
||||||
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = "2026-02-20T00:00:00Z"
|
|
||||||
goVersion = "go1.23.0"
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Equal(t, buildTime, build)
|
|
||||||
assert.Equal(t, goVersion, goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = ""
|
|
||||||
goVersion = "go1.23.0"
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Empty(t, build)
|
|
||||||
assert.Equal(t, goVersion, goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = "x"
|
|
||||||
goVersion = ""
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Equal(t, "x", build)
|
|
||||||
assert.Equal(t, runtime.Version(), goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetConfigPath_Windows(t *testing.T) {
|
func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
if runtime.GOOS != "windows" {
|
if runtime.GOOS != "windows" {
|
||||||
t.Skip("windows-specific HOME behavior varies; run on windows")
|
t.Skip("windows-specific HOME behavior varies; run on windows")
|
||||||
|
|
@ -112,17 +53,3 @@ func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
|
|
||||||
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetVersion(t *testing.T) {
|
|
||||||
assert.Equal(t, "dev", GetVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetConfigPath_WithEnv(t *testing.T) {
|
|
||||||
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
|
|
||||||
t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred
|
|
||||||
|
|
||||||
got := GetConfigPath()
|
|
||||||
want := "/tmp/custom/config.json"
|
|
||||||
|
|
||||||
assert.Equal(t, want, got)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func statusCmd() {
|
func statusCmd() {
|
||||||
|
|
@ -18,8 +19,8 @@ func statusCmd() {
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||||
fmt.Printf("Version: %s\n", internal.FormatVersion())
|
fmt.Printf("Version: %s\n", config.FormatVersion())
|
||||||
build, _ := internal.FormatBuildInfo()
|
build, _ := config.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf("Build: %s\n", build)
|
fmt.Printf("Build: %s\n", build)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewVersionCommand() *cobra.Command {
|
func NewVersionCommand() *cobra.Command {
|
||||||
|
|
@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
func printVersion() {
|
func printVersion() {
|
||||||
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
|
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
|
||||||
build, goVer := internal.FormatBuildInfo()
|
build, goVer := config.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf(" Build: %s\n", build)
|
fmt.Printf(" Build: %s\n", build)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,11 @@ import (
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPicoclawCommand() *cobra.Command {
|
func NewPicoclawCommand() *cobra.Command {
|
||||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "picoclaw",
|
Use: "picoclaw",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewPicoclawCommand(t *testing.T) {
|
func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||||
|
|
||||||
assert.Equal(t, "picoclaw", cmd.Use)
|
assert.Equal(t, "picoclaw", cmd.Use)
|
||||||
assert.Equal(t, short, cmd.Short)
|
assert.Equal(t, short, cmd.Short)
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,9 @@
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"api_keys": [
|
||||||
|
"YOUR_BRAVE_API_KEY"
|
||||||
|
],
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"tavily": {
|
"tavily": {
|
||||||
|
|
@ -299,7 +302,10 @@
|
||||||
},
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "",
|
"api_key": "pplx-xxx",
|
||||||
|
"api_keys": [
|
||||||
|
"pplx-xxx"
|
||||||
|
],
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"searxng": {
|
"searxng": {
|
||||||
|
|
|
||||||
12
docker/Dockerfile.goreleaser.launcher
Normal file
12
docker/Dockerfile.goreleaser.launcher
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
|
||||||
|
|
||||||
|
ENTRYPOINT ["picoclaw-launcher"]
|
||||||
|
CMD ["-public", "-no-browser"]
|
||||||
|
|
@ -19,7 +19,7 @@ services:
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Gateway (Long-running Bot)
|
# PicoClaw Gateway (Long-running Bot)
|
||||||
# docker compose -f docker/docker-compose.yml up picoclaw-gateway
|
# docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
image: docker.io/sipeed/picoclaw:latest
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
|
|
@ -32,3 +32,21 @@ services:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/root/.picoclaw
|
- ./data:/root/.picoclaw
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PicoClaw Launcher (Web Console + Gateway)
|
||||||
|
# docker compose -f docker/docker-compose.yml --profile launcher up
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
picoclaw-launcher:
|
||||||
|
image: docker.io/sipeed/picoclaw:launcher
|
||||||
|
container_name: picoclaw-launcher
|
||||||
|
restart: on-failure
|
||||||
|
profiles:
|
||||||
|
- launcher
|
||||||
|
environment:
|
||||||
|
- PICOCLAW_GATEWAY_HOST=0.0.0.0
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:18800:18800"
|
||||||
|
- "127.0.0.1:18790:18790"
|
||||||
|
volumes:
|
||||||
|
- ./data:/root/.picoclaw
|
||||||
|
|
|
||||||
33
docs/debug.md
Normal file
33
docs/debug.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Debugging PicoClaw
|
||||||
|
|
||||||
|
PicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates.
|
||||||
|
## Starting PicoClaw in Debug Mode
|
||||||
|
|
||||||
|
To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway --debug
|
||||||
|
# or
|
||||||
|
picoclaw gateway -d
|
||||||
|
```
|
||||||
|
|
||||||
|
In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results.
|
||||||
|
|
||||||
|
## Disabling Log Truncation (Full Logs)
|
||||||
|
|
||||||
|
By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable.
|
||||||
|
|
||||||
|
If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag.
|
||||||
|
|
||||||
|
**Note:** This flag *only* works when combined with the `--debug` mode.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway --debug --no-truncate
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
When this flag is active, the global truncation function is disabled. This is extremely useful for:
|
||||||
|
|
||||||
|
* Verifying the exact syntax of the messages sent to the provider.
|
||||||
|
* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`.
|
||||||
|
* Debugging the session history saved in memory.
|
||||||
|
|
@ -12,9 +12,11 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
|
|
@ -80,8 +82,10 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
func (cb *ContextBuilder) getIdentity() string {
|
func (cb *ContextBuilder) getIdentity() string {
|
||||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||||
toolDiscovery := cb.getDiscoveryRule()
|
toolDiscovery := cb.getDiscoveryRule()
|
||||||
|
version := config.FormatVersion()
|
||||||
|
|
||||||
return fmt.Sprintf(`# picoclaw 🦞
|
return fmt.Sprintf(
|
||||||
|
`# picoclaw 🦞 (%s)
|
||||||
|
|
||||||
You are picoclaw, a helpful AI assistant.
|
You are picoclaw, a helpful AI assistant.
|
||||||
|
|
||||||
|
|
@ -102,7 +106,7 @@ Your workspace is at: %s
|
||||||
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
|
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
|
||||||
|
|
||||||
%s`,
|
%s`,
|
||||||
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
|
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) getDiscoveryRule() string {
|
func (cb *ContextBuilder) getDiscoveryRule() string {
|
||||||
|
|
@ -535,10 +539,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
})
|
})
|
||||||
|
|
||||||
// Log preview of system prompt (avoid logging huge content)
|
// Log preview of system prompt (avoid logging huge content)
|
||||||
preview := fullSystemPrompt
|
preview := utils.Truncate(fullSystemPrompt, 500)
|
||||||
if len(preview) > 500 {
|
|
||||||
preview = preview[:500] + "... (truncated)"
|
|
||||||
}
|
|
||||||
logger.DebugCF("agent", "System prompt preview",
|
logger.DebugCF("agent", "System prompt preview",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -9,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
|
@ -31,7 +33,7 @@ type AgentInstance struct {
|
||||||
SummarizeMessageThreshold int
|
SummarizeMessageThreshold int
|
||||||
SummarizeTokenPercent int
|
SummarizeTokenPercent int
|
||||||
Provider providers.LLMProvider
|
Provider providers.LLMProvider
|
||||||
Sessions *session.SessionManager
|
Sessions session.SessionStore
|
||||||
ContextBuilder *ContextBuilder
|
ContextBuilder *ContextBuilder
|
||||||
Tools *tools.ToolRegistry
|
Tools *tools.ToolRegistry
|
||||||
Subagents *config.SubagentsConfig
|
Subagents *config.SubagentsConfig
|
||||||
|
|
@ -98,7 +100,7 @@ func NewAgentInstance(
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
sessions := initSessionStore(sessionsDir)
|
||||||
|
|
||||||
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
||||||
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
|
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
|
||||||
|
|
@ -229,7 +231,7 @@ func NewAgentInstance(
|
||||||
SummarizeMessageThreshold: summarizeMessageThreshold,
|
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||||
SummarizeTokenPercent: summarizeTokenPercent,
|
SummarizeTokenPercent: summarizeTokenPercent,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Sessions: sessionsManager,
|
Sessions: sessions,
|
||||||
ContextBuilder: contextBuilder,
|
ContextBuilder: contextBuilder,
|
||||||
Tools: toolsRegistry,
|
Tools: toolsRegistry,
|
||||||
Subagents: subagents,
|
Subagents: subagents,
|
||||||
|
|
@ -283,6 +285,39 @@ func compilePatterns(patterns []string) []*regexp.Regexp {
|
||||||
return compiled
|
return compiled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close releases resources held by the agent's session store.
|
||||||
|
func (a *AgentInstance) Close() error {
|
||||||
|
if a.Sessions != nil {
|
||||||
|
return a.Sessions.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initSessionStore creates the session persistence backend.
|
||||||
|
// It uses the JSONL store by default and auto-migrates legacy JSON sessions.
|
||||||
|
// Falls back to SessionManager if the JSONL store cannot be initialized or
|
||||||
|
// if migration fails (which indicates the store cannot write reliably).
|
||||||
|
func initSessionStore(dir string) session.SessionStore {
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("memory: init store: %v; using json sessions", err)
|
||||||
|
return session.NewSessionManager(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, merr := memory.MigrateFromJSON(context.Background(), dir, store); merr != nil {
|
||||||
|
// Migration failure means the store could not write data.
|
||||||
|
// Fall back to SessionManager to avoid a split state where
|
||||||
|
// some sessions are in JSONL and others remain in JSON.
|
||||||
|
log.Printf("memory: migration failed: %v; falling back to json sessions", merr)
|
||||||
|
store.Close()
|
||||||
|
return session.NewSessionManager(dir)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("memory: migrated %d session(s) to jsonl", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.NewJSONLBackend(store)
|
||||||
|
}
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return path
|
return path
|
||||||
|
|
|
||||||
|
|
@ -120,19 +120,21 @@ func registerSharedTools(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web tools
|
|
||||||
if cfg.Tools.IsToolEnabled("web") {
|
if cfg.Tools.IsToolEnabled("web") {
|
||||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
PerplexityAPIKeys: config.MergeAPIKeys(
|
||||||
|
cfg.Tools.Web.Perplexity.APIKey,
|
||||||
|
cfg.Tools.Web.Perplexity.APIKeys,
|
||||||
|
),
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||||
|
|
@ -427,6 +429,11 @@ func (al *AgentLoop) Stop() {
|
||||||
al.running.Store(false)
|
al.running.Store(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close releases resources held by agent session stores. Call after Stop.
|
||||||
|
func (al *AgentLoop) Close() {
|
||||||
|
al.registry.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
||||||
for _, agentID := range al.registry.ListAgentIDs() {
|
for _, agentID := range al.registry.ListAgentIDs() {
|
||||||
if agent, ok := al.registry.GetAgent(agentID); ok {
|
if agent, ok := al.registry.GetAgent(agentID); ok {
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,18 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close releases resources held by all registered agents.
|
||||||
|
func (r *AgentRegistry) Close() {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, agent := range r.agents {
|
||||||
|
if err := agent.Close(); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to close agent",
|
||||||
|
map[string]any{"agent_id": agent.ID, "error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultAgent returns the default agent instance.
|
// GetDefaultAgent returns the default agent instance.
|
||||||
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID, err := parseChatID(msg.ChatID)
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
@ -200,7 +200,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil {
|
if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -210,9 +210,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
|
|
||||||
// sendHTMLChunk sends a single HTML message, falling back to the original
|
// sendHTMLChunk sends a single HTML message, falling back to the original
|
||||||
// markdown as plain text on parse failure so users never see raw HTML tags.
|
// markdown as plain text on parse failure so users never see raw HTML tags.
|
||||||
func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error {
|
func (c *TelegramChannel) sendHTMLChunk(
|
||||||
|
ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string,
|
||||||
|
) error {
|
||||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
|
tgMsg.MessageThreadID = threadID
|
||||||
|
|
||||||
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
|
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
|
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
|
||||||
|
|
@ -232,13 +235,16 @@ func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlC
|
||||||
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
||||||
// The returned stop function is idempotent and cancels the goroutine.
|
// The returned stop function is idempotent and cancels the goroutine.
|
||||||
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
cid, err := parseChatID(chatID)
|
cid, threadID, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return func() {}, err
|
return func() {}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||||
|
action.MessageThreadID = threadID
|
||||||
|
|
||||||
// Send the first typing action immediately
|
// Send the first typing action immediately
|
||||||
_ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
|
_ = c.bot.SendChatAction(ctx, action)
|
||||||
|
|
||||||
typingCtx, cancel := context.WithCancel(ctx)
|
typingCtx, cancel := context.WithCancel(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -249,7 +255,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
|
||||||
case <-typingCtx.Done():
|
case <-typingCtx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
|
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||||
|
a.MessageThreadID = threadID
|
||||||
|
_ = c.bot.SendChatAction(typingCtx, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -259,7 +267,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
|
||||||
|
|
||||||
// EditMessage implements channels.MessageEditor.
|
// EditMessage implements channels.MessageEditor.
|
||||||
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
cid, err := parseChatID(chatID)
|
cid, _, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -288,12 +296,14 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
|
||||||
text = "Thinking... 💭"
|
text = "Thinking... 💭"
|
||||||
}
|
}
|
||||||
|
|
||||||
cid, err := parseChatID(chatID)
|
cid, threadID, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text))
|
phMsg := tu.Message(tu.ID(cid), text)
|
||||||
|
phMsg.MessageThreadID = threadID
|
||||||
|
pMsg, err := c.bot.SendMessage(ctx, phMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +317,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID, err := parseChatID(msg.ChatID)
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
@ -339,30 +349,34 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
switch part.Type {
|
switch part.Type {
|
||||||
case "image":
|
case "image":
|
||||||
params := &telego.SendPhotoParams{
|
params := &telego.SendPhotoParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
Photo: telego.InputFile{File: file},
|
MessageThreadID: threadID,
|
||||||
Caption: part.Caption,
|
Photo: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
}
|
}
|
||||||
_, err = c.bot.SendPhoto(ctx, params)
|
_, err = c.bot.SendPhoto(ctx, params)
|
||||||
case "audio":
|
case "audio":
|
||||||
params := &telego.SendAudioParams{
|
params := &telego.SendAudioParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
Audio: telego.InputFile{File: file},
|
MessageThreadID: threadID,
|
||||||
Caption: part.Caption,
|
Audio: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
}
|
}
|
||||||
_, err = c.bot.SendAudio(ctx, params)
|
_, err = c.bot.SendAudio(ctx, params)
|
||||||
case "video":
|
case "video":
|
||||||
params := &telego.SendVideoParams{
|
params := &telego.SendVideoParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
Video: telego.InputFile{File: file},
|
MessageThreadID: threadID,
|
||||||
Caption: part.Caption,
|
Video: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
}
|
}
|
||||||
_, err = c.bot.SendVideo(ctx, params)
|
_, err = c.bot.SendVideo(ctx, params)
|
||||||
default: // "file" or unknown types
|
default: // "file" or unknown types
|
||||||
params := &telego.SendDocumentParams{
|
params := &telego.SendDocumentParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
Document: telego.InputFile{File: file},
|
MessageThreadID: threadID,
|
||||||
Caption: part.Caption,
|
Document: telego.InputFile{File: file},
|
||||||
|
Caption: part.Caption,
|
||||||
}
|
}
|
||||||
_, err = c.bot.SendDocument(ctx, params)
|
_, err = c.bot.SendDocument(ctx, params)
|
||||||
}
|
}
|
||||||
|
|
@ -506,19 +520,28 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
content = cleaned
|
content = cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For forum topics, embed the thread ID as "chatID/threadID" so replies
|
||||||
|
// route to the correct topic and each topic gets its own session.
|
||||||
|
// Only forum groups (IsForum) are handled; regular group reply threads
|
||||||
|
// must share one session per group.
|
||||||
|
compositeChatID := fmt.Sprintf("%d", chatID)
|
||||||
|
threadID := message.MessageThreadID
|
||||||
|
if message.Chat.IsForum && threadID != 0 {
|
||||||
|
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
|
||||||
|
}
|
||||||
|
|
||||||
logger.DebugCF("telegram", "Received message", map[string]any{
|
logger.DebugCF("telegram", "Received message", map[string]any{
|
||||||
"sender_id": sender.CanonicalID,
|
"sender_id": sender.CanonicalID,
|
||||||
"chat_id": fmt.Sprintf("%d", chatID),
|
"chat_id": compositeChatID,
|
||||||
|
"thread_id": threadID,
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable
|
|
||||||
|
|
||||||
peerKind := "direct"
|
peerKind := "direct"
|
||||||
peerID := fmt.Sprintf("%d", user.ID)
|
peerID := fmt.Sprintf("%d", user.ID)
|
||||||
if message.Chat.Type != "private" {
|
if message.Chat.Type != "private" {
|
||||||
peerKind = "group"
|
peerKind = "group"
|
||||||
peerID = fmt.Sprintf("%d", chatID)
|
peerID = compositeChatID
|
||||||
}
|
}
|
||||||
|
|
||||||
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
||||||
|
|
@ -531,11 +554,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set parent_peer metadata for per-topic agent binding.
|
||||||
|
if message.Chat.IsForum && threadID != 0 {
|
||||||
|
metadata["parent_peer_kind"] = "topic"
|
||||||
|
metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID)
|
||||||
|
}
|
||||||
|
|
||||||
c.HandleMessage(c.ctx,
|
c.HandleMessage(c.ctx,
|
||||||
peer,
|
peer,
|
||||||
messageID,
|
messageID,
|
||||||
platformID,
|
platformID,
|
||||||
fmt.Sprintf("%d", chatID),
|
compositeChatID,
|
||||||
content,
|
content,
|
||||||
mediaPaths,
|
mediaPaths,
|
||||||
metadata,
|
metadata,
|
||||||
|
|
@ -583,10 +612,23 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string)
|
||||||
return c.downloadFileWithInfo(file, ext)
|
return c.downloadFileWithInfo(file, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseChatID(chatIDStr string) (int64, error) {
|
// parseTelegramChatID splits "chatID/threadID" into its components.
|
||||||
var id int64
|
// Returns threadID=0 when no "/" is present (non-forum messages).
|
||||||
_, err := fmt.Sscanf(chatIDStr, "%d", &id)
|
func parseTelegramChatID(chatID string) (int64, int, error) {
|
||||||
return id, err
|
idx := strings.Index(chatID, "/")
|
||||||
|
if idx == -1 {
|
||||||
|
cid, err := strconv.ParseInt(chatID, 10, 64)
|
||||||
|
return cid, 0, err
|
||||||
|
}
|
||||||
|
cid, err := strconv.ParseInt(chatID[:idx], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
tid, err := strconv.Atoi(chatID[idx+1:])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err)
|
||||||
|
}
|
||||||
|
return cid, tid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func markdownToTelegramHTML(text string) string {
|
func markdownToTelegramHTML(text string) string {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
ta "github.com/mymmrac/telego/telegoapi"
|
ta "github.com/mymmrac/telego/telegoapi"
|
||||||
|
|
@ -271,3 +272,191 @@ func TestSend_InvalidChatID(t *testing.T) {
|
||||||
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
|
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
|
||||||
assert.Empty(t, caller.calls)
|
assert.Empty(t, caller.calls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_Plain(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("12345")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(12345), cid)
|
||||||
|
assert.Equal(t, 0, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_NegativeGroup(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("-1001234567890")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(-1001234567890), cid)
|
||||||
|
assert.Equal(t, 0, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_WithThreadID(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("-1001234567890/42")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(-1001234567890), cid)
|
||||||
|
assert.Equal(t, 42, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_GeneralTopic(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("-100123/1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(-100123), cid)
|
||||||
|
assert.Equal(t, 1, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_Invalid(t *testing.T) {
|
||||||
|
_, _, err := parseTelegramChatID("not-a-number")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_InvalidThreadID(t *testing.T) {
|
||||||
|
_, _, err := parseTelegramChatID("-100123/not-a-thread")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid thread ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_WithForumThreadID(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "-1001234567890/42",
|
||||||
|
Content: "Hello from topic",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, caller.calls, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "hello from topic",
|
||||||
|
MessageID: 10,
|
||||||
|
MessageThreadID: 42,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: -1001234567890,
|
||||||
|
Type: "supergroup",
|
||||||
|
IsForum: true,
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 7,
|
||||||
|
FirstName: "Alice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
require.True(t, ok, "expected inbound message")
|
||||||
|
|
||||||
|
// Composite chatID should include thread ID
|
||||||
|
assert.Equal(t, "-1001234567890/42", inbound.ChatID)
|
||||||
|
|
||||||
|
// Peer ID should include thread ID for session key isolation
|
||||||
|
assert.Equal(t, "group", inbound.Peer.Kind)
|
||||||
|
assert.Equal(t, "-1001234567890/42", inbound.Peer.ID)
|
||||||
|
|
||||||
|
// Parent peer metadata should be set for agent binding
|
||||||
|
assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"])
|
||||||
|
assert.Equal(t, "42", inbound.Metadata["parent_peer_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "regular group message",
|
||||||
|
MessageID: 11,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: -100999,
|
||||||
|
Type: "group",
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 8,
|
||||||
|
FirstName: "Bob",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
// Plain chatID without thread suffix
|
||||||
|
assert.Equal(t, "-100999", inbound.ChatID)
|
||||||
|
|
||||||
|
// Peer ID should be raw chat ID (no thread suffix)
|
||||||
|
assert.Equal(t, "group", inbound.Peer.Kind)
|
||||||
|
assert.Equal(t, "-100999", inbound.Peer.ID)
|
||||||
|
|
||||||
|
// No parent peer metadata
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// In regular groups, reply threads set MessageThreadID to the original
|
||||||
|
// message ID. This should NOT trigger per-thread session isolation.
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "reply in thread",
|
||||||
|
MessageID: 20,
|
||||||
|
MessageThreadID: 15,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: -100999,
|
||||||
|
Type: "supergroup",
|
||||||
|
IsForum: false,
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 9,
|
||||||
|
FirstName: "Carol",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
// chatID should NOT include thread suffix for non-forum groups
|
||||||
|
assert.Equal(t, "-100999", inbound.ChatID)
|
||||||
|
|
||||||
|
// Peer ID should be raw chat ID (shared session for whole group)
|
||||||
|
assert.Equal(t, "group", inbound.Peer.Kind)
|
||||||
|
assert.Equal(t, "-100999", inbound.Peer.ID)
|
||||||
|
|
||||||
|
// No parent peer metadata
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_id"])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
|
@ -58,6 +59,16 @@ type Config struct {
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
Devices DevicesConfig `json:"devices"`
|
||||||
|
// BuildInfo contains build-time version information
|
||||||
|
BuildInfo BuildInfo `json:"build_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildInfo contains build-time version information
|
||||||
|
type BuildInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
GitCommit string `json:"git_commit"`
|
||||||
|
BuildTime string `json:"build_time"`
|
||||||
|
GoVersion string `json:"go_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for Config
|
// MarshalJSON implements custom JSON marshaling for Config
|
||||||
|
|
@ -593,16 +604,18 @@ type ToolConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TavilyConfig struct {
|
type TavilyConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuckDuckGoConfig struct {
|
type DuckDuckGoConfig struct {
|
||||||
|
|
@ -611,9 +624,10 @@ type DuckDuckGoConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerplexityConfig struct {
|
type PerplexityConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearXNGConfig struct {
|
type SearXNGConfig struct {
|
||||||
|
|
@ -934,6 +948,29 @@ func (c *Config) ValidateModelList() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var all []string
|
||||||
|
|
||||||
|
if k := strings.TrimSpace(apiKey); k != "" {
|
||||||
|
if _, exists := seen[k]; !exists {
|
||||||
|
seen[k] = struct{}{}
|
||||||
|
all = append(all, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, k := range apiKeys {
|
||||||
|
if trimmed := strings.TrimSpace(k); trimmed != "" {
|
||||||
|
if _, exists := seen[trimmed]; !exists {
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
all = append(all, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
switch name {
|
switch name {
|
||||||
case "web":
|
case "web":
|
||||||
|
|
|
||||||
|
|
@ -296,7 +296,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
|
||||||
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
||||||
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.Brave.APIKey != "" {
|
if len(cfg.Tools.Web.Brave.APIKeys) != 0 {
|
||||||
t.Error("Brave API key should be empty by default")
|
t.Error("Brave API key should be empty by default")
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,13 @@ func DefaultConfig() *Config {
|
||||||
Brave: BraveConfig{
|
Brave: BraveConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
|
Tavily: TavilyConfig{
|
||||||
|
Enabled: false,
|
||||||
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
DuckDuckGo: DuckDuckGoConfig{
|
DuckDuckGo: DuckDuckGoConfig{
|
||||||
|
|
@ -393,6 +400,7 @@ func DefaultConfig() *Config {
|
||||||
Perplexity: PerplexityConfig{
|
Perplexity: PerplexityConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
APIKeys: nil,
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
SearXNG: SearXNGConfig{
|
SearXNG: SearXNGConfig{
|
||||||
|
|
@ -502,5 +510,11 @@ func DefaultConfig() *Config {
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
MonitorUSB: true,
|
MonitorUSB: true,
|
||||||
},
|
},
|
||||||
|
BuildInfo: BuildInfo{
|
||||||
|
Version: Version,
|
||||||
|
GitCommit: GitCommit,
|
||||||
|
BuildTime: BuildTime,
|
||||||
|
GoVersion: GoVersion,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
pkg/config/version.go
Normal file
44
pkg/config/version.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build-time variables injected via ldflags during build process.
|
||||||
|
// These are set by the Makefile or .goreleaser.yaml using the -X flag:
|
||||||
|
//
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.Version=<version>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.GitCommit=<commit>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.BuildTime=<timestamp>
|
||||||
|
// -X github.com/sipeed/picoclaw/pkg/config.GoVersion=<go-version>
|
||||||
|
var (
|
||||||
|
Version = "dev" // Default value when not built with ldflags
|
||||||
|
GitCommit string // Git commit SHA (short)
|
||||||
|
BuildTime string // Build timestamp in RFC3339 format
|
||||||
|
GoVersion string // Go version used for building
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
func FormatVersion() string {
|
||||||
|
v := Version
|
||||||
|
if GitCommit != "" {
|
||||||
|
v += fmt.Sprintf(" (git: %s)", GitCommit)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBuildInfo returns build time and go version info
|
||||||
|
func FormatBuildInfo() (string, string) {
|
||||||
|
build := BuildTime
|
||||||
|
goVer := GoVersion
|
||||||
|
if goVer == "" {
|
||||||
|
goVer = runtime.Version()
|
||||||
|
}
|
||||||
|
return build, goVer
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion returns the version string
|
||||||
|
func GetVersion() string {
|
||||||
|
return Version
|
||||||
|
}
|
||||||
92
pkg/config/version_test.go
Normal file
92
pkg/config/version_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := Version, GitCommit
|
||||||
|
t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
Version = "1.2.3"
|
||||||
|
GitCommit = ""
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := Version, GitCommit
|
||||||
|
t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
Version = "1.2.3"
|
||||||
|
GitCommit = "abc123"
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = "2026-02-20T00:00:00Z"
|
||||||
|
GoVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, BuildTime, build)
|
||||||
|
assert.Equal(t, GoVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = ""
|
||||||
|
GoVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Empty(t, build)
|
||||||
|
assert.Equal(t, GoVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := BuildTime, GoVersion
|
||||||
|
t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
BuildTime = "x"
|
||||||
|
GoVersion = ""
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, "x", build)
|
||||||
|
assert.Equal(t, runtime.Version(), goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion(t *testing.T) {
|
||||||
|
oldVersion := Version
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
Version = "dev"
|
||||||
|
assert.Equal(t, "dev", GetVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion_Custom(t *testing.T) {
|
||||||
|
oldVersion := Version
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
Version = "v1.0.0"
|
||||||
|
assert.Equal(t, "v1.0.0", GetVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersion_DefaultIsDev(t *testing.T) {
|
||||||
|
// Reset to default values
|
||||||
|
oldVersion := Version
|
||||||
|
Version = "dev"
|
||||||
|
t.Cleanup(func() { Version = oldVersion })
|
||||||
|
|
||||||
|
assert.Equal(t, "dev", Version)
|
||||||
|
}
|
||||||
|
|
@ -86,14 +86,14 @@ func (s *JSONLStore) metaPath(key string) string {
|
||||||
|
|
||||||
// sanitizeKey converts a session key to a safe filename component.
|
// sanitizeKey converts a session key to a safe filename component.
|
||||||
// Mirrors pkg/session.sanitizeFilename so that migration paths match.
|
// Mirrors pkg/session.sanitizeFilename so that migration paths match.
|
||||||
//
|
// Replaces ':' with '_' (session key separator) and '/' and '\' with '_'
|
||||||
// Note: this is a lossy mapping — "telegram:123" and "telegram_123"
|
// so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts")
|
||||||
// both produce the same filename. This is an intentional tradeoff:
|
// do not create subdirectories or break on Windows.
|
||||||
// keys with colons (e.g. from channels) are by far the common case,
|
|
||||||
// and a bidirectional encoding (like URL-encoding) would complicate
|
|
||||||
// file listings and debugging.
|
|
||||||
func sanitizeKey(key string) string {
|
func sanitizeKey(key string) string {
|
||||||
return strings.ReplaceAll(key, ":", "_")
|
s := strings.ReplaceAll(key, ":", "_")
|
||||||
|
s = strings.ReplaceAll(s, "/", "_")
|
||||||
|
s = strings.ReplaceAll(s, "\\", "_")
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// readMeta loads the metadata file for a session.
|
// readMeta loads the metadata file for a session.
|
||||||
|
|
|
||||||
|
|
@ -733,16 +733,18 @@ type WebToolsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
MaxResults int `json:"max_results"`
|
APIKeys []string `json:"api_keys"`
|
||||||
|
MaxResults int `json:"max_results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TavilyConfig struct {
|
type TavilyConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
BaseURL string `json:"base_url"`
|
APIKeys []string `json:"api_keys"`
|
||||||
MaxResults int `json:"max_results"`
|
BaseURL string `json:"base_url"`
|
||||||
|
MaxResults int `json:"max_results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuckDuckGoConfig struct {
|
type DuckDuckGoConfig struct {
|
||||||
|
|
@ -751,9 +753,10 @@ type DuckDuckGoConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerplexityConfig struct {
|
type PerplexityConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
MaxResults int `json:"max_results"`
|
APIKeys []string `json:"api_keys"`
|
||||||
|
MaxResults int `json:"max_results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronConfig struct {
|
type CronConfig struct {
|
||||||
|
|
@ -1082,6 +1085,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig {
|
||||||
Brave: config.BraveConfig{
|
Brave: config.BraveConfig{
|
||||||
Enabled: c.Web.Brave.Enabled,
|
Enabled: c.Web.Brave.Enabled,
|
||||||
APIKey: c.Web.Brave.APIKey,
|
APIKey: c.Web.Brave.APIKey,
|
||||||
|
APIKeys: c.Web.Brave.APIKeys,
|
||||||
MaxResults: c.Web.Brave.MaxResults,
|
MaxResults: c.Web.Brave.MaxResults,
|
||||||
},
|
},
|
||||||
Tavily: config.TavilyConfig{
|
Tavily: config.TavilyConfig{
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||||
lowerModel := strings.ToLower(model)
|
lowerModel := strings.ToLower(model)
|
||||||
|
|
||||||
|
if providerName == "" && model == "" {
|
||||||
|
return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty")
|
||||||
|
}
|
||||||
|
|
||||||
sel := providerSelection{
|
sel := providerSelection{
|
||||||
providerType: providerTypeHTTPCompat,
|
providerType: providerTypeHTTPCompat,
|
||||||
model: model,
|
model: model,
|
||||||
|
|
|
||||||
81
pkg/session/jsonl_backend.go
Normal file
81
pkg/session/jsonl_backend.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONLBackend adapts a memory.Store into the SessionStore interface.
|
||||||
|
// Write errors are logged rather than returned, matching the fire-and-forget
|
||||||
|
// contract of SessionManager that the agent loop relies on.
|
||||||
|
type JSONLBackend struct {
|
||||||
|
store memory.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJSONLBackend wraps a memory.Store for use as a SessionStore.
|
||||||
|
func NewJSONLBackend(store memory.Store) *JSONLBackend {
|
||||||
|
return &JSONLBackend{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) AddMessage(sessionKey, role, content string) {
|
||||||
|
if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil {
|
||||||
|
log.Printf("session: add message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) {
|
||||||
|
if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil {
|
||||||
|
log.Printf("session: add full message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) GetHistory(key string) []providers.Message {
|
||||||
|
msgs, err := b.store.GetHistory(context.Background(), key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("session: get history: %v", err)
|
||||||
|
return []providers.Message{}
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) GetSummary(key string) string {
|
||||||
|
summary, err := b.store.GetSummary(context.Background(), key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("session: get summary: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) SetSummary(key, summary string) {
|
||||||
|
if err := b.store.SetSummary(context.Background(), key, summary); err != nil {
|
||||||
|
log.Printf("session: set summary: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) SetHistory(key string, history []providers.Message) {
|
||||||
|
if err := b.store.SetHistory(context.Background(), key, history); err != nil {
|
||||||
|
log.Printf("session: set history: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *JSONLBackend) TruncateHistory(key string, keepLast int) {
|
||||||
|
if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil {
|
||||||
|
log.Printf("session: truncate history: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save persists session state. Since the JSONL store fsyncs every write
|
||||||
|
// immediately, the data is already durable. Save runs compaction to reclaim
|
||||||
|
// space from logically truncated messages (no-op when there are none).
|
||||||
|
func (b *JSONLBackend) Save(key string) error {
|
||||||
|
return b.store.Compact(context.Background(), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases resources held by the underlying store.
|
||||||
|
func (b *JSONLBackend) Close() error {
|
||||||
|
return b.store.Close()
|
||||||
|
}
|
||||||
179
pkg/session/jsonl_backend_test.go
Normal file
179
pkg/session/jsonl_backend_test.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package session_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time interface satisfaction checks.
|
||||||
|
var (
|
||||||
|
_ session.SessionStore = (*session.SessionManager)(nil)
|
||||||
|
_ session.SessionStore = (*session.JSONLBackend)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBackend(t *testing.T) *session.JSONLBackend {
|
||||||
|
t.Helper()
|
||||||
|
store, err := memory.NewJSONLStore(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
return session.NewJSONLBackend(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_AddAndGetHistory(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
b.AddMessage("s1", "user", "hello")
|
||||||
|
b.AddMessage("s1", "assistant", "hi")
|
||||||
|
|
||||||
|
history := b.GetHistory("s1")
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("got %d messages, want 2", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Role != "user" || history[0].Content != "hello" {
|
||||||
|
t.Errorf("msg[0] = %+v", history[0])
|
||||||
|
}
|
||||||
|
if history[1].Role != "assistant" || history[1].Content != "hi" {
|
||||||
|
t.Errorf("msg[1] = %+v", history[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_AddFullMessage(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
msg := providers.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "done",
|
||||||
|
ToolCalls: []providers.ToolCall{
|
||||||
|
{ID: "tc1", Function: &providers.FunctionCall{Name: "read_file", Arguments: `{"path":"x"}`}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b.AddFullMessage("s1", msg)
|
||||||
|
|
||||||
|
history := b.GetHistory("s1")
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("got %d, want 1", len(history))
|
||||||
|
}
|
||||||
|
if len(history[0].ToolCalls) != 1 || history[0].ToolCalls[0].ID != "tc1" {
|
||||||
|
t.Errorf("tool calls = %+v", history[0].ToolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_Summary(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
if got := b.GetSummary("s1"); got != "" {
|
||||||
|
t.Errorf("got %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.SetSummary("s1", "test summary")
|
||||||
|
if got := b.GetSummary("s1"); got != "test summary" {
|
||||||
|
t.Errorf("got %q, want %q", got, "test summary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_TruncateAndSave(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i))
|
||||||
|
}
|
||||||
|
b.TruncateHistory("s1", 3)
|
||||||
|
|
||||||
|
history := b.GetHistory("s1")
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Fatalf("got %d, want 3", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "msg 7" {
|
||||||
|
t.Errorf("got %q, want %q", history[0].Content, "msg 7")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save triggers compaction.
|
||||||
|
if err := b.Save("s1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages still accessible after compaction.
|
||||||
|
history = b.GetHistory("s1")
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Fatalf("after save: got %d, want 3", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_SetHistory(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
b.AddMessage("s1", "user", "old")
|
||||||
|
|
||||||
|
b.SetHistory("s1", []providers.Message{
|
||||||
|
{Role: "user", Content: "new1"},
|
||||||
|
{Role: "assistant", Content: "new2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
history := b.GetHistory("s1")
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("got %d, want 2", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "new1" {
|
||||||
|
t.Errorf("got %q, want %q", history[0].Content, "new1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_EmptySession(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
history := b.GetHistory("nonexistent")
|
||||||
|
if history == nil {
|
||||||
|
t.Fatal("got nil, want empty slice")
|
||||||
|
}
|
||||||
|
if len(history) != 0 {
|
||||||
|
t.Errorf("got %d, want 0", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_SessionIsolation(t *testing.T) {
|
||||||
|
b := newBackend(t)
|
||||||
|
b.AddMessage("s1", "user", "session1")
|
||||||
|
b.AddMessage("s2", "user", "session2")
|
||||||
|
|
||||||
|
h1 := b.GetHistory("s1")
|
||||||
|
h2 := b.GetHistory("s2")
|
||||||
|
|
||||||
|
if len(h1) != 1 || h1[0].Content != "session1" {
|
||||||
|
t.Errorf("s1: %+v", h1)
|
||||||
|
}
|
||||||
|
if len(h2) != 1 || h2[0].Content != "session2" {
|
||||||
|
t.Errorf("s2: %+v", h2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLBackend_SummarizeFlow(t *testing.T) {
|
||||||
|
// Simulates the real summarization flow in the agent loop:
|
||||||
|
// SetSummary → TruncateHistory → Save
|
||||||
|
b := newBackend(t)
|
||||||
|
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.SetSummary("s1", "conversation about testing")
|
||||||
|
b.TruncateHistory("s1", 4)
|
||||||
|
if err := b.Save("s1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := b.GetSummary("s1"); got != "conversation about testing" {
|
||||||
|
t.Errorf("summary = %q", got)
|
||||||
|
}
|
||||||
|
history := b.GetHistory("s1")
|
||||||
|
if len(history) != 4 {
|
||||||
|
t.Fatalf("got %d messages, want 4", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Content != "msg 16" {
|
||||||
|
t.Errorf("first message = %q, want %q", history[0].Content, "msg 16")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -146,12 +146,15 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizeFilename converts a session key into a cross-platform safe filename.
|
// sanitizeFilename converts a session key into a cross-platform safe filename.
|
||||||
// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the
|
// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so
|
||||||
// volume separator on Windows, so filepath.Base would misinterpret the key.
|
// composite IDs (e.g. Telegram forum "chatID/threadID") do not create
|
||||||
// We replace it with '_'. The original key is preserved inside the JSON file,
|
// subdirectories or break on Windows. The original key is preserved inside
|
||||||
// so loadSessions still maps back to the right in-memory key.
|
// the JSON file, so loadSessions still maps back to the right in-memory key.
|
||||||
func sanitizeFilename(key string) string {
|
func sanitizeFilename(key string) string {
|
||||||
return strings.ReplaceAll(key, ":", "_")
|
s := strings.ReplaceAll(key, ":", "_")
|
||||||
|
s = strings.ReplaceAll(s, "/", "_")
|
||||||
|
s = strings.ReplaceAll(s, "\\", "_")
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SessionManager) Save(key string) error {
|
func (sm *SessionManager) Save(key string) error {
|
||||||
|
|
@ -162,10 +165,9 @@ func (sm *SessionManager) Save(key string) error {
|
||||||
filename := sanitizeFilename(key)
|
filename := sanitizeFilename(key)
|
||||||
|
|
||||||
// filepath.IsLocal rejects empty names, "..", absolute paths, and
|
// filepath.IsLocal rejects empty names, "..", absolute paths, and
|
||||||
// OS-reserved device names (NUL, COM1 … on Windows).
|
// OS-reserved device names (NUL, COM1 … on Windows). sanitizeFilename
|
||||||
// The extra checks reject "." and any directory separators so that
|
// already replaced '/' and '\' with '_', so no subdirs are created.
|
||||||
// the session file is always written directly inside sm.storage.
|
if filename == "." || !filepath.IsLocal(filename) {
|
||||||
if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) {
|
|
||||||
return os.ErrInvalid
|
return os.ErrInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,6 +267,12 @@ func (sm *SessionManager) loadSessions() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close is a no-op for the in-memory SessionManager; it satisfies the
|
||||||
|
// SessionStore interface so callers can release resources uniformly.
|
||||||
|
func (sm *SessionManager) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetHistory updates the messages of a session.
|
// SetHistory updates the messages of a session.
|
||||||
func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
|
func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ func TestSanitizeFilename(t *testing.T) {
|
||||||
{"slack:C01234", "slack_C01234"},
|
{"slack:C01234", "slack_C01234"},
|
||||||
{"no-colons-here", "no-colons-here"},
|
{"no-colons-here", "no-colons-here"},
|
||||||
{"multiple:colons:here", "multiple_colons_here"},
|
{"multiple:colons:here", "multiple_colons_here"},
|
||||||
|
{"agent:main:telegram:group:-1003822706455/12", "agent_main_telegram_group_-1003822706455_12"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
@ -64,11 +65,21 @@ func TestSave_RejectsPathTraversal(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
sm := NewSessionManager(tmpDir)
|
sm := NewSessionManager(tmpDir)
|
||||||
|
|
||||||
badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"}
|
// Invalid names that must still be rejected.
|
||||||
|
badKeys := []string{"", ".", ".."}
|
||||||
for _, key := range badKeys {
|
for _, key := range badKeys {
|
||||||
sm.GetOrCreate(key)
|
sm.GetOrCreate(key)
|
||||||
if err := sm.Save(key); err == nil {
|
if err := sm.Save(key); err == nil {
|
||||||
t.Errorf("Save(%q) should have failed but didn't", key)
|
t.Errorf("Save(%q) should have failed but didn't", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keys containing path separators are sanitized (no subdirs created).
|
||||||
|
sm.GetOrCreate("foo/bar")
|
||||||
|
if err := sm.Save("foo/bar"); err != nil {
|
||||||
|
t.Fatalf("Save(\"foo/bar\") after sanitize should succeed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(tmpDir, "foo_bar.json")); os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
pkg/session/session_store.go
Normal file
32
pkg/session/session_store.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import "github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
|
||||||
|
// SessionStore defines the persistence operations used by the agent loop.
|
||||||
|
// Both SessionManager (legacy JSON backend) and JSONLBackend satisfy this
|
||||||
|
// interface, allowing the storage layer to be swapped without touching the
|
||||||
|
// agent loop code.
|
||||||
|
//
|
||||||
|
// Write methods (Add*, Set*, Truncate*) are fire-and-forget: they do not
|
||||||
|
// return errors. Implementations should log failures internally. This
|
||||||
|
// matches the original SessionManager contract that the agent loop relies on.
|
||||||
|
type SessionStore interface {
|
||||||
|
// AddMessage appends a simple role/content message to the session.
|
||||||
|
AddMessage(sessionKey, role, content string)
|
||||||
|
// AddFullMessage appends a complete message including tool calls.
|
||||||
|
AddFullMessage(sessionKey string, msg providers.Message)
|
||||||
|
// GetHistory returns the full message history for the session.
|
||||||
|
GetHistory(key string) []providers.Message
|
||||||
|
// GetSummary returns the conversation summary, or "" if none.
|
||||||
|
GetSummary(key string) string
|
||||||
|
// SetSummary replaces the conversation summary.
|
||||||
|
SetSummary(key, summary string)
|
||||||
|
// SetHistory replaces the full message history.
|
||||||
|
SetHistory(key string, history []providers.Message)
|
||||||
|
// TruncateHistory keeps only the last keepLast messages.
|
||||||
|
TruncateHistory(key string, keepLast int)
|
||||||
|
// Save persists any pending state to durable storage.
|
||||||
|
Save(key string) error
|
||||||
|
// Close releases resources held by the store.
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
483
pkg/tools/web.go
483
pkg/tools/web.go
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -76,81 +77,140 @@ func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, err
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type APIKeyPool struct {
|
||||||
|
keys []string
|
||||||
|
current uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAPIKeyPool(keys []string) *APIKeyPool {
|
||||||
|
return &APIKeyPool{
|
||||||
|
keys: keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKeyIterator struct {
|
||||||
|
pool *APIKeyPool
|
||||||
|
startIdx uint32
|
||||||
|
attempt uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *APIKeyPool) NewIterator() *APIKeyIterator {
|
||||||
|
if len(p.keys) == 0 {
|
||||||
|
return &APIKeyIterator{pool: p}
|
||||||
|
}
|
||||||
|
idx := atomic.AddUint32(&p.current, 1) - 1
|
||||||
|
return &APIKeyIterator{
|
||||||
|
pool: p,
|
||||||
|
startIdx: idx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (it *APIKeyIterator) Next() (string, bool) {
|
||||||
|
length := uint32(len(it.pool.keys))
|
||||||
|
if length == 0 || it.attempt >= length {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
key := it.pool.keys[(it.startIdx+it.attempt)%length]
|
||||||
|
it.attempt++
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
|
||||||
type SearchProvider interface {
|
type SearchProvider interface {
|
||||||
Search(ctx context.Context, query string, count int) (string, error)
|
Search(ctx context.Context, query string, count int) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type BraveSearchProvider struct {
|
type BraveSearchProvider struct {
|
||||||
apiKey string
|
keyPool *APIKeyPool
|
||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
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) {
|
||||||
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
|
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
|
||||||
url.QueryEscape(query), count)
|
url.QueryEscape(query), count)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
var lastErr error
|
||||||
if err != nil {
|
iter := p.keyPool.NewIterator()
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
for {
|
||||||
req.Header.Set("X-Subscription-Token", p.apiKey)
|
apiKey, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
resp, err := p.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("brave api error (status %d): %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchResp struct {
|
|
||||||
Web struct {
|
|
||||||
Results []struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
} `json:"results"`
|
|
||||||
} `json:"web"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
|
||||||
// Log error body for debugging
|
|
||||||
fmt.Printf("Brave API Error Body: %s\n", string(body))
|
|
||||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
results := searchResp.Web.Results
|
|
||||||
if len(results) == 0 {
|
|
||||||
return fmt.Sprintf("No results for: %s", query), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
lines = append(lines, fmt.Sprintf("Results for: %s", query))
|
|
||||||
for i, item := range results {
|
|
||||||
if i >= count {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
|
|
||||||
if item.Description != "" {
|
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
|
||||||
lines = append(lines, fmt.Sprintf(" %s", item.Description))
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("X-Subscription-Token", apiKey)
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("request failed: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
lastErr = fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
resp.StatusCode == http.StatusUnauthorized ||
|
||||||
|
resp.StatusCode == http.StatusForbidden ||
|
||||||
|
resp.StatusCode >= 500 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp struct {
|
||||||
|
Web struct {
|
||||||
|
Results []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"results"`
|
||||||
|
} `json:"web"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
|
// Log error body for debugging
|
||||||
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := searchResp.Web.Results
|
||||||
|
if len(results) == 0 {
|
||||||
|
return fmt.Sprintf("No results for: %s", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("Results for: %s", query))
|
||||||
|
for i, item := range results {
|
||||||
|
if i >= count {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
|
||||||
|
if item.Description != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" %s", item.Description))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(lines, "\n"), nil
|
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TavilySearchProvider struct {
|
type TavilySearchProvider struct {
|
||||||
apiKey string
|
keyPool *APIKeyPool
|
||||||
baseURL string
|
baseURL string
|
||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
@ -162,74 +222,96 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
||||||
searchURL = "https://api.tavily.com/search"
|
searchURL = "https://api.tavily.com/search"
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := map[string]any{
|
var lastErr error
|
||||||
"api_key": p.apiKey,
|
iter := p.keyPool.NewIterator()
|
||||||
"query": query,
|
|
||||||
"search_depth": "advanced",
|
|
||||||
"include_answer": false,
|
|
||||||
"include_images": false,
|
|
||||||
"include_raw_content": false,
|
|
||||||
"max_results": count,
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(payload)
|
for {
|
||||||
if err != nil {
|
apiKey, ok := iter.Next()
|
||||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
if !ok {
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchResp struct {
|
|
||||||
Results []struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"results"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
results := searchResp.Results
|
|
||||||
if len(results) == 0 {
|
|
||||||
return fmt.Sprintf("No results for: %s", query), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
lines = append(lines, fmt.Sprintf("Results for: %s (via Tavily)", query))
|
|
||||||
for i, item := range results {
|
|
||||||
if i >= count {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
|
|
||||||
if item.Content != "" {
|
payload := map[string]any{
|
||||||
lines = append(lines, fmt.Sprintf(" %s", item.Content))
|
"api_key": apiKey,
|
||||||
|
"query": query,
|
||||||
|
"search_depth": "advanced",
|
||||||
|
"include_answer": false,
|
||||||
|
"include_images": false,
|
||||||
|
"include_raw_content": false,
|
||||||
|
"max_results": count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("request failed: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
lastErr = fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
resp.StatusCode == http.StatusUnauthorized ||
|
||||||
|
resp.StatusCode == http.StatusForbidden ||
|
||||||
|
resp.StatusCode >= 500 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp struct {
|
||||||
|
Results []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := searchResp.Results
|
||||||
|
if len(results) == 0 {
|
||||||
|
return fmt.Sprintf("No results for: %s", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("Results for: %s (via Tavily)", query))
|
||||||
|
for i, item := range results {
|
||||||
|
if i >= count {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
|
||||||
|
if item.Content != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" %s", item.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(lines, "\n"), nil
|
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuckDuckGoSearchProvider struct {
|
type DuckDuckGoSearchProvider struct {
|
||||||
|
|
@ -324,75 +406,97 @@ func stripTags(content string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerplexitySearchProvider struct {
|
type PerplexitySearchProvider struct {
|
||||||
apiKey string
|
keyPool *APIKeyPool
|
||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
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) {
|
||||||
searchURL := "https://api.perplexity.ai/chat/completions"
|
searchURL := "https://api.perplexity.ai/chat/completions"
|
||||||
|
|
||||||
payload := map[string]any{
|
var lastErr error
|
||||||
"model": "sonar",
|
iter := p.keyPool.NewIterator()
|
||||||
"messages": []map[string]string{
|
|
||||||
{
|
for {
|
||||||
"role": "system",
|
apiKey, ok := iter.Next()
|
||||||
"content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.",
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"model": "sonar",
|
||||||
|
"messages": []map[string]string{
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
"max_tokens": 1000,
|
||||||
"role": "user",
|
}
|
||||||
"content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count),
|
|
||||||
},
|
payloadBytes, err := json.Marshal(payload)
|
||||||
},
|
if err != nil {
|
||||||
"max_tokens": 1000,
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("request failed: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
lastErr = fmt.Errorf("Perplexity API error: %s", string(body))
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
resp.StatusCode == http.StatusUnauthorized ||
|
||||||
|
resp.StatusCode == http.StatusForbidden ||
|
||||||
|
resp.StatusCode >= 500 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Choices) == 0 {
|
||||||
|
return fmt.Sprintf("No results for: %s", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
payloadBytes, err := json.Marshal(payload)
|
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes)))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("Perplexity API error: %s", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchResp struct {
|
|
||||||
Choices []struct {
|
|
||||||
Message struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"message"`
|
|
||||||
} `json:"choices"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(searchResp.Choices) == 0 {
|
|
||||||
return fmt.Sprintf("No results for: %s", query), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearXNGSearchProvider struct {
|
type SearXNGSearchProvider struct {
|
||||||
|
|
@ -545,16 +649,16 @@ type WebSearchTool struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSearchToolOptions struct {
|
type WebSearchToolOptions struct {
|
||||||
BraveAPIKey string
|
BraveAPIKeys []string
|
||||||
BraveMaxResults int
|
BraveMaxResults int
|
||||||
BraveEnabled bool
|
BraveEnabled bool
|
||||||
TavilyAPIKey string
|
TavilyAPIKeys []string
|
||||||
TavilyBaseURL string
|
TavilyBaseURL string
|
||||||
TavilyMaxResults int
|
TavilyMaxResults int
|
||||||
TavilyEnabled bool
|
TavilyEnabled bool
|
||||||
DuckDuckGoMaxResults int
|
DuckDuckGoMaxResults int
|
||||||
DuckDuckGoEnabled bool
|
DuckDuckGoEnabled bool
|
||||||
PerplexityAPIKey string
|
PerplexityAPIKeys []string
|
||||||
PerplexityMaxResults int
|
PerplexityMaxResults int
|
||||||
PerplexityEnabled bool
|
PerplexityEnabled bool
|
||||||
SearXNGBaseURL string
|
SearXNGBaseURL string
|
||||||
|
|
@ -571,23 +675,26 @@ type WebSearchToolOptions struct {
|
||||||
func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
var provider SearchProvider
|
var provider SearchProvider
|
||||||
maxResults := 5
|
maxResults := 5
|
||||||
|
|
||||||
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
|
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search
|
||||||
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
|
if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err)
|
||||||
}
|
}
|
||||||
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy, client: client}
|
provider = &PerplexitySearchProvider{
|
||||||
|
keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys),
|
||||||
|
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 && len(opts.BraveAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err)
|
||||||
}
|
}
|
||||||
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy, client: client}
|
provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client}
|
||||||
if opts.BraveMaxResults > 0 {
|
if opts.BraveMaxResults > 0 {
|
||||||
maxResults = opts.BraveMaxResults
|
maxResults = opts.BraveMaxResults
|
||||||
}
|
}
|
||||||
|
|
@ -596,13 +703,13 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
if opts.SearXNGMaxResults > 0 {
|
if opts.SearXNGMaxResults > 0 {
|
||||||
maxResults = opts.SearXNGMaxResults
|
maxResults = opts.SearXNGMaxResults
|
||||||
}
|
}
|
||||||
} else if opts.TavilyEnabled && opts.TavilyAPIKey != "" {
|
} else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {
|
||||||
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
|
||||||
}
|
}
|
||||||
provider = &TavilySearchProvider{
|
provider = &TavilySearchProvider{
|
||||||
apiKey: opts.TavilyAPIKey,
|
keyPool: NewAPIKeyPool(opts.TavilyAPIKeys),
|
||||||
baseURL: opts.TavilyBaseURL,
|
baseURL: opts.TavilyBaseURL,
|
||||||
proxy: opts.Proxy,
|
proxy: opts.Proxy,
|
||||||
client: client,
|
client: client,
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,7 @@ func TestWebFetchTool_PayloadTooLarge(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, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""})
|
tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -269,7 +269,11 @@ 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, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
BraveEnabled: true,
|
||||||
|
BraveAPIKeys: []string{"test-key"},
|
||||||
|
BraveMaxResults: 5,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -553,7 +557,7 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
||||||
t.Run("perplexity", func(t *testing.T) {
|
t.Run("perplexity", func(t *testing.T) {
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
PerplexityEnabled: true,
|
PerplexityEnabled: true,
|
||||||
PerplexityAPIKey: "k",
|
PerplexityAPIKeys: []string{"k"},
|
||||||
PerplexityMaxResults: 3,
|
PerplexityMaxResults: 3,
|
||||||
Proxy: "http://127.0.0.1:7890",
|
Proxy: "http://127.0.0.1:7890",
|
||||||
})
|
})
|
||||||
|
|
@ -572,7 +576,7 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
||||||
t.Run("brave", func(t *testing.T) {
|
t.Run("brave", func(t *testing.T) {
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
BraveEnabled: true,
|
BraveEnabled: true,
|
||||||
BraveAPIKey: "k",
|
BraveAPIKeys: []string{"k"},
|
||||||
BraveMaxResults: 3,
|
BraveMaxResults: 3,
|
||||||
Proxy: "http://127.0.0.1:7890",
|
Proxy: "http://127.0.0.1:7890",
|
||||||
})
|
})
|
||||||
|
|
@ -650,7 +654,7 @@ func TestWebTool_TavilySearch_Success(t *testing.T) {
|
||||||
|
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
TavilyEnabled: true,
|
TavilyEnabled: true,
|
||||||
TavilyAPIKey: "test-key",
|
TavilyAPIKeys: []string{"test-key"},
|
||||||
TavilyBaseURL: server.URL,
|
TavilyBaseURL: server.URL,
|
||||||
TavilyMaxResults: 5,
|
TavilyMaxResults: 5,
|
||||||
})
|
})
|
||||||
|
|
@ -682,6 +686,121 @@ func TestWebTool_TavilySearch_Success(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyPool(t *testing.T) {
|
||||||
|
pool := NewAPIKeyPool([]string{"key1", "key2", "key3"})
|
||||||
|
if len(pool.keys) != 3 {
|
||||||
|
t.Fatalf("expected 3 keys, got %d", len(pool.keys))
|
||||||
|
}
|
||||||
|
if pool.keys[0] != "key1" || pool.keys[1] != "key2" || pool.keys[2] != "key3" {
|
||||||
|
t.Fatalf("unexpected keys: %v", pool.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Iterator: each iterator should cover all keys exactly once
|
||||||
|
iter := pool.NewIterator()
|
||||||
|
expected := []string{"key1", "key2", "key3"}
|
||||||
|
for i, want := range expected {
|
||||||
|
k, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("iter.Next() returned false at step %d", i)
|
||||||
|
}
|
||||||
|
if k != want {
|
||||||
|
t.Errorf("step %d: expected %s, got %s", i, want, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Should be exhausted
|
||||||
|
if _, ok := iter.Next(); ok {
|
||||||
|
t.Errorf("expected iterator exhausted after all keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second iterator starts at next position (load balancing)
|
||||||
|
iter2 := pool.NewIterator()
|
||||||
|
k, ok := iter2.Next()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("iter2.Next() returned false")
|
||||||
|
}
|
||||||
|
if k != "key2" {
|
||||||
|
t.Errorf("expected key2 (round-robin), got %s", k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty pool
|
||||||
|
emptyPool := NewAPIKeyPool([]string{})
|
||||||
|
emptyIter := emptyPool.NewIterator()
|
||||||
|
if _, ok := emptyIter.Next(); ok {
|
||||||
|
t.Errorf("expected false for empty pool")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single key pool
|
||||||
|
singlePool := NewAPIKeyPool([]string{"single"})
|
||||||
|
singleIter := singlePool.NewIterator()
|
||||||
|
if k, ok := singleIter.Next(); !ok || k != "single" {
|
||||||
|
t.Errorf("expected single, got %s (ok=%v)", k, ok)
|
||||||
|
}
|
||||||
|
if _, ok := singleIter.Next(); ok {
|
||||||
|
t.Errorf("expected exhausted after single key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebTool_TavilySearch_Failover(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := payload["api_key"].(string)
|
||||||
|
|
||||||
|
if apiKey == "key1" {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte("Rate limited"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey == "key2" {
|
||||||
|
// Success
|
||||||
|
response := map[string]any{
|
||||||
|
"results": []map[string]any{
|
||||||
|
{
|
||||||
|
"title": "Success Result",
|
||||||
|
"url": "https://example.com/success",
|
||||||
|
"content": "Success content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
TavilyEnabled: true,
|
||||||
|
TavilyAPIKeys: []string{"key1", "key2"},
|
||||||
|
TavilyBaseURL: server.URL,
|
||||||
|
TavilyMaxResults: 5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
args := map[string]any{
|
||||||
|
"query": "test query",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success, got Error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForUser, "Success Result") {
|
||||||
|
t.Errorf("Expected failover to second key and success result, got: %s", result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebTool_GLMSearch_Success(t *testing.T) {
|
func TestWebTool_GLMSearch_Success(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,18 @@ package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Global variable to disable truncation
|
||||||
|
var disableTruncation atomic.Bool
|
||||||
|
|
||||||
|
// SetDisableTruncation globally enables or disables string truncation
|
||||||
|
func SetDisableTruncation(enabled bool) {
|
||||||
|
disableTruncation.Store(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
// SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides,
|
// SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides,
|
||||||
// zero-width characters), and other non-graphic characters that could confuse an LLM
|
// zero-width characters), and other non-graphic characters that could confuse an LLM
|
||||||
// or cause display issues in the agent UI.
|
// or cause display issues in the agent UI.
|
||||||
|
|
@ -30,6 +39,10 @@ func SanitizeMessageContent(input string) string {
|
||||||
// Handles multi-byte Unicode characters properly.
|
// Handles multi-byte Unicode characters properly.
|
||||||
// If the string is truncated, "..." is appended to indicate truncation.
|
// If the string is truncated, "..." is appended to indicate truncation.
|
||||||
func Truncate(s string, maxLen int) string {
|
func Truncate(s string, maxLen int) string {
|
||||||
|
// If the no-truncate flag is active, it returns the full string
|
||||||
|
if disableTruncation.Load() {
|
||||||
|
return s
|
||||||
|
}
|
||||||
if maxLen <= 0 {
|
if maxLen <= 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,12 @@ func (h *Handler) startGatewayLocked() (int, error) {
|
||||||
execPath := findPicoclawBinary()
|
execPath := findPicoclawBinary()
|
||||||
|
|
||||||
cmd := exec.Command(execPath, "gateway")
|
cmd := exec.Command(execPath, "gateway")
|
||||||
|
// Forward the launcher's config path via the environment variable that
|
||||||
|
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||||
|
// config file without requiring a --config flag on the gateway subcommand.
|
||||||
|
if h.configPath != "" {
|
||||||
|
cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+h.configPath)
|
||||||
|
}
|
||||||
|
|
||||||
stdoutPipe, err := cmd.StdoutPipe()
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -530,18 +536,32 @@ func (h *Handler) currentGatewayStatus() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// findPicoclawBinary locates the picoclaw executable.
|
// findPicoclawBinary locates the picoclaw executable.
|
||||||
// Tries the same directory as the current executable first, then falls back to $PATH.
|
// Search order:
|
||||||
|
// 1. PICOCLAW_BINARY environment variable (explicit override)
|
||||||
|
// 2. Same directory as the current executable
|
||||||
|
// 3. Falls back to "picoclaw" and relies on $PATH
|
||||||
func findPicoclawBinary() string {
|
func findPicoclawBinary() string {
|
||||||
if exe, err := os.Executable(); err == nil {
|
binaryName := "picoclaw"
|
||||||
dir := filepath.Dir(exe)
|
if runtime.GOOS == "windows" {
|
||||||
candidate := filepath.Join(dir, "picoclaw")
|
binaryName = "picoclaw.exe"
|
||||||
if runtime.GOOS == "windows" {
|
}
|
||||||
candidate += ".exe"
|
|
||||||
|
// 1. Explicit override via environment variable
|
||||||
|
if p := os.Getenv("PICOCLAW_BINARY"); p != "" {
|
||||||
|
if info, _ := os.Stat(p); info != nil && !info.IsDir() {
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Same directory as the launcher executable
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
candidate := filepath.Join(filepath.Dir(exe), binaryName)
|
||||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Fall back to PATH lookup
|
||||||
return "picoclaw"
|
return "picoclaw"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -120,3 +121,30 @@ func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
|
||||||
t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"])
|
t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
|
||||||
|
// Create a temporary file to act as the mock binary
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mockBinary := filepath.Join(tmpDir, "picoclaw-mock")
|
||||||
|
if err := os.WriteFile(mockBinary, []byte("mock"), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_BINARY", mockBinary)
|
||||||
|
|
||||||
|
got := findPicoclawBinary()
|
||||||
|
if got != mockBinary {
|
||||||
|
t.Errorf("findPicoclawBinary() = %q, want %q", got, mockBinary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) {
|
||||||
|
// When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy
|
||||||
|
t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary")
|
||||||
|
|
||||||
|
got := findPicoclawBinary()
|
||||||
|
// Should not return the invalid path; falls back to "picoclaw" or another found path
|
||||||
|
if got == "/nonexistent/picoclaw-binary" {
|
||||||
|
t.Errorf("findPicoclawBinary() returned invalid env path %q, expected fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ PicoClaw 🦞
|
||||||
## Description
|
## Description
|
||||||
Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
|
Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
|
||||||
|
|
||||||
## Version
|
|
||||||
0.1.0
|
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
- Provide intelligent AI assistance with minimal resource usage
|
- Provide intelligent AI assistance with minimal resource usage
|
||||||
- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)
|
- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue