Merge branch 'sipeed:main' into main
This commit is contained in:
commit
1761f0a7de
45 changed files with 735 additions and 263 deletions
|
|
@ -66,7 +66,6 @@ linters:
|
|||
- testifylint
|
||||
- thelper
|
||||
- unparam
|
||||
- unused
|
||||
- usestdlibvars
|
||||
- usetesting
|
||||
- wastedassign
|
||||
|
|
@ -152,6 +151,9 @@ linters:
|
|||
- gocognit
|
||||
- gocyclo
|
||||
path: _test\.go$
|
||||
- linters:
|
||||
- nolintlint
|
||||
path: 'pkg/tools/(i2c\.go|spi\.go)$'
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -14,7 +14,7 @@ GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
|||
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
|
||||
|
||||
# Go variables
|
||||
GO?=go
|
||||
GO?=CGO_ENABLED=0 go
|
||||
GOFLAGS?=-v -tags stdjson
|
||||
|
||||
# Golangci-lint
|
||||
|
|
@ -144,6 +144,10 @@ fmt:
|
|||
lint:
|
||||
@$(GOLANGCI_LINT) run
|
||||
|
||||
## fix: Fix linting issues
|
||||
fix:
|
||||
@$(GOLANGCI_LINT) run --fix
|
||||
|
||||
## deps: Download dependencies
|
||||
deps:
|
||||
@$(GO) mod download
|
||||
|
|
@ -169,7 +173,7 @@ help:
|
|||
@echo " make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'
|
||||
@grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make build # Build for current platform"
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ picoclaw onboard
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt4"
|
||||
"model_name": "gpt4"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ picoclaw onboard
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt4"
|
||||
"model_name": "gpt4"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@
|
|||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<br>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
||||
|
|
@ -219,7 +222,7 @@ picoclaw onboard
|
|||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "gpt4",
|
||||
"model_name": "gpt4",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ picoclaw onboard
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt4"
|
||||
"model_name": "gpt4"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ picoclaw onboard
|
|||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "gpt4"
|
||||
"model_name": "gpt4"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ picoclaw onboard
|
|||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "gpt4",
|
||||
"model_name": "gpt4",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 147 KiB |
|
|
@ -56,7 +56,7 @@ func agentCmd() {
|
|||
}
|
||||
|
||||
if modelOverride != "" {
|
||||
cfg.Agents.Defaults.Model = modelOverride
|
||||
cfg.Agents.Defaults.ModelName = modelOverride
|
||||
}
|
||||
|
||||
provider, modelID, err := providers.CreateProvider(cfg)
|
||||
|
|
@ -66,7 +66,7 @@ func agentCmd() {
|
|||
}
|
||||
// Use the resolved model ID from provider creation
|
||||
if modelID != "" {
|
||||
cfg.Agents.Defaults.Model = modelID
|
||||
cfg.Agents.Defaults.ModelName = modelID
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||
}
|
||||
|
||||
// Update default model to use OpenAI
|
||||
appCfg.Agents.Defaults.Model = "gpt-5.2"
|
||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
|
|
@ -218,7 +218,7 @@ func authLoginGoogleAntigravity() {
|
|||
}
|
||||
|
||||
// Update default model
|
||||
appCfg.Agents.Defaults.Model = "gemini-flash"
|
||||
appCfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
|
|
@ -292,7 +292,7 @@ func authLoginPasteToken(provider string) {
|
|||
})
|
||||
}
|
||||
// Update default model
|
||||
appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
|
||||
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||
case "openai":
|
||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||
// Update ModelList
|
||||
|
|
@ -312,7 +312,7 @@ func authLoginPasteToken(provider string) {
|
|||
})
|
||||
}
|
||||
// Update default model
|
||||
appCfg.Agents.Defaults.Model = "gpt-5.2"
|
||||
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||
}
|
||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||
|
|
@ -320,7 +320,7 @@ func authLoginPasteToken(provider string) {
|
|||
}
|
||||
|
||||
fmt.Printf("Token saved for %s!\n", provider)
|
||||
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
|
||||
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
|
||||
}
|
||||
|
||||
func authLogoutCmd() {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ func gatewayCmd() {
|
|||
}
|
||||
// Use the resolved model ID from provider creation
|
||||
if modelID != "" {
|
||||
cfg.Agents.Defaults.Model = modelID
|
||||
cfg.Agents.Defaults.ModelName = modelID
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
|
@ -211,6 +211,9 @@ func gatewayCmd() {
|
|||
<-sigChan
|
||||
|
||||
fmt.Println("\nShutting down...")
|
||||
if cp, ok := provider.(providers.StatefulProvider); ok {
|
||||
cp.Close()
|
||||
}
|
||||
cancel()
|
||||
healthServer.Stop(context.Background())
|
||||
deviceService.Stop()
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ func statusCmd() {
|
|||
}
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
|
||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
|
||||
|
||||
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
||||
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ func main() {
|
|||
|
||||
workspace := cfg.WorkspacePath()
|
||||
installer := skills.NewSkillInstaller(workspace)
|
||||
// 获取全局配置目录和内置 skills 目录
|
||||
// get global config directory and builtin skills directory
|
||||
globalDir := filepath.Dir(getConfigPath())
|
||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true,
|
||||
"model": "gpt4",
|
||||
"model_name": "gpt4",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
|
|
@ -217,7 +217,8 @@
|
|||
"enabled": false,
|
||||
"api_key": "pplx-xxx",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"proxy": ""
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
|
|
|
|||
|
|
@ -288,25 +288,6 @@ func (cb *ContextBuilder) AddAssistantMessage(
|
|||
return messages
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) loadSkills() string {
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
if len(allSkills) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var skillNames []string
|
||||
for _, s := range allSkills {
|
||||
skillNames = append(skillNames, s.Name)
|
||||
}
|
||||
|
||||
content := cb.skillsLoader.LoadSkillsForContext(skillNames)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "# Skill Definitions\n\n" + content
|
||||
}
|
||||
|
||||
// GetSkillsInfo returns information about loaded skills.
|
||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefau
|
|||
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
|
||||
return strings.TrimSpace(agentCfg.Model.Primary)
|
||||
}
|
||||
return defaults.Model
|
||||
return defaults.GetModelName()
|
||||
}
|
||||
|
||||
// resolveAgentFallbacks resolves the fallback models for an agent.
|
||||
|
|
|
|||
|
|
@ -106,10 +106,11 @@ func registerSharedTools(
|
|||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||
Proxy: cfg.Tools.Web.Proxy,
|
||||
}); searchTool != nil {
|
||||
agent.Tools.Register(searchTool)
|
||||
}
|
||||
agent.Tools.Register(tools.NewWebFetchTool(50000))
|
||||
agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy))
|
||||
|
||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||
agent.Tools.Register(tools.NewI2CTool())
|
||||
|
|
@ -626,8 +627,9 @@ func (al *AgentLoop) runLLMIteration(
|
|||
|
||||
// Build assistant message with tool calls
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: response.Content,
|
||||
Role: "assistant",
|
||||
Content: response.Content,
|
||||
ReasoningContent: response.ReasoningContent,
|
||||
}
|
||||
for _, tc := range normalizedToolCalls {
|
||||
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
||||
|
|
|
|||
|
|
@ -47,31 +47,31 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
|||
|
||||
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
|
||||
|
||||
// 创建 token source
|
||||
// create token source
|
||||
credentials := &token.QQBotCredentials{
|
||||
AppID: c.config.AppID,
|
||||
AppSecret: c.config.AppSecret,
|
||||
}
|
||||
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
||||
|
||||
// 创建子 context
|
||||
// create child context
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
// 启动自动刷新 token 协程
|
||||
// start auto-refresh token goroutine
|
||||
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
|
||||
return fmt.Errorf("failed to start token refresh: %w", err)
|
||||
}
|
||||
|
||||
// 初始化 OpenAPI 客户端
|
||||
// initialize OpenAPI client
|
||||
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
|
||||
|
||||
// 注册事件处理器
|
||||
// register event handlers
|
||||
intent := event.RegisterHandlers(
|
||||
c.handleC2CMessage(),
|
||||
c.handleGroupATMessage(),
|
||||
)
|
||||
|
||||
// 获取 WebSocket 接入点
|
||||
// get WebSocket endpoint
|
||||
wsInfo, err := c.api.WS(c.ctx, nil, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get websocket info: %w", err)
|
||||
|
|
@ -81,10 +81,10 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
|||
"shards": wsInfo.Shards,
|
||||
})
|
||||
|
||||
// 创建并保存 sessionManager
|
||||
// create and save sessionManager
|
||||
c.sessionManager = botgo.NewSessionManager()
|
||||
|
||||
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
|
||||
// start WebSocket connection in goroutine to avoid blocking
|
||||
go func() {
|
||||
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
|
||||
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
|
||||
|
|
@ -116,12 +116,12 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|||
return fmt.Errorf("QQ bot not running")
|
||||
}
|
||||
|
||||
// 构造消息
|
||||
// construct message
|
||||
msgToCreate := &dto.MessageToCreate{
|
||||
Content: msg.Content,
|
||||
}
|
||||
|
||||
// C2C 消息发送
|
||||
// send C2C message
|
||||
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
||||
if err != nil {
|
||||
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
|
||||
|
|
@ -133,15 +133,15 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// handleC2CMessage 处理 QQ 私聊消息
|
||||
// handleC2CMessage handles QQ private messages
|
||||
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
||||
// 去重检查
|
||||
// deduplication check
|
||||
if c.isDuplicate(data.ID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 提取用户信息
|
||||
// extract user info
|
||||
var senderID string
|
||||
if data.Author != nil && data.Author.ID != "" {
|
||||
senderID = data.Author.ID
|
||||
|
|
@ -150,7 +150,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
|||
return nil
|
||||
}
|
||||
|
||||
// 提取消息内容
|
||||
// extract message content
|
||||
content := data.Content
|
||||
if content == "" {
|
||||
logger.DebugC("qq", "Received empty message, ignoring")
|
||||
|
|
@ -162,7 +162,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
|||
"length": len(content),
|
||||
})
|
||||
|
||||
// 转发到消息总线
|
||||
// forward to message bus
|
||||
metadata := map[string]string{
|
||||
"message_id": data.ID,
|
||||
"peer_kind": "direct",
|
||||
|
|
@ -175,15 +175,15 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
|||
}
|
||||
}
|
||||
|
||||
// handleGroupATMessage 处理群@消息
|
||||
// handleGroupATMessage handles group @messages
|
||||
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||
// 去重检查
|
||||
// deduplication check
|
||||
if c.isDuplicate(data.ID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 提取用户信息
|
||||
// extract user info
|
||||
var senderID string
|
||||
if data.Author != nil && data.Author.ID != "" {
|
||||
senderID = data.Author.ID
|
||||
|
|
@ -192,7 +192,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
|||
return nil
|
||||
}
|
||||
|
||||
// 提取消息内容(去掉 @ 机器人部分)
|
||||
// extract message content (remove @bot part)
|
||||
content := data.Content
|
||||
if content == "" {
|
||||
logger.DebugC("qq", "Received empty group message, ignoring")
|
||||
|
|
@ -205,7 +205,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
|||
"length": len(content),
|
||||
})
|
||||
|
||||
// 转发到消息总线(使用 GroupID 作为 ChatID)
|
||||
// forward to message bus (use GroupID as ChatID)
|
||||
metadata := map[string]string{
|
||||
"message_id": data.ID,
|
||||
"group_id": data.GroupID,
|
||||
|
|
@ -219,7 +219,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
|||
}
|
||||
}
|
||||
|
||||
// isDuplicate 检查消息是否重复
|
||||
// isDuplicate checks if message is duplicate
|
||||
func (c *QQChannel) isDuplicate(messageID string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
|
@ -230,9 +230,9 @@ func (c *QQChannel) isDuplicate(messageID string) bool {
|
|||
|
||||
c.processedIDs[messageID] = true
|
||||
|
||||
// 简单清理:限制 map 大小
|
||||
// simple cleanup: limit map size
|
||||
if len(c.processedIDs) > 10000 {
|
||||
// 清空一半
|
||||
// clear half
|
||||
count := 0
|
||||
for id := range c.processedIDs {
|
||||
if count >= 5000 {
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
return
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
// check allowlist to avoid downloading attachments for rejected users
|
||||
if !c.IsAllowed(ev.User) {
|
||||
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": ev.User,
|
||||
|
|
@ -232,9 +232,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
|||
content = c.stripBotMention(content)
|
||||
|
||||
var mediaPaths []string
|
||||
localFiles := []string{} // 跟踪需要清理的本地文件
|
||||
localFiles := []string{} // track local files that need cleanup
|
||||
|
||||
// 确保临时文件在函数返回时被清理
|
||||
// ensure temp files are cleaned up when function returns
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
// check allowlist to avoid downloading attachments for rejected users
|
||||
if !c.IsAllowed(senderID) {
|
||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": senderID,
|
||||
|
|
@ -221,9 +221,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
|
||||
content := ""
|
||||
mediaPaths := []string{}
|
||||
localFiles := []string{} // 跟踪需要清理的本地文件
|
||||
localFiles := []string{} // track local files that need cleanup
|
||||
|
||||
// 确保临时文件在函数返回时被清理
|
||||
// ensure temp files are cleaned up when function returns
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
|
|||
switch args {
|
||||
case "model":
|
||||
response = fmt.Sprintf("Current Model: %s (Provider: %s)",
|
||||
c.config.Agents.Defaults.Model,
|
||||
c.config.Agents.Defaults.GetModelName(),
|
||||
c.config.Agents.Defaults.Provider)
|
||||
case "channel":
|
||||
response = "Current Channel: telegram"
|
||||
|
|
@ -120,7 +120,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
|||
provider = "configured default"
|
||||
}
|
||||
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
|
||||
c.config.Agents.Defaults.Model, provider)
|
||||
c.config.Agents.Defaults.GetModelName(), provider)
|
||||
|
||||
case "channels":
|
||||
var enabled []string
|
||||
|
|
|
|||
|
|
@ -571,61 +571,6 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user
|
|||
return nil
|
||||
}
|
||||
|
||||
// sendMarkdownMessage sends a markdown message to a user
|
||||
func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, userID, content string) error {
|
||||
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
|
||||
|
||||
msg := WeComMarkdownMessage{
|
||||
ToUser: userID,
|
||||
MsgType: "markdown",
|
||||
AgentID: c.config.AgentID,
|
||||
}
|
||||
msg.Markdown.Content = content
|
||||
|
||||
jsonData, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
// Use configurable timeout (default 5 seconds)
|
||||
timeout := c.config.ReplyTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var sendResp WeComSendMessageResponse
|
||||
if err := json.Unmarshal(body, &sendResp); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if sendResp.ErrCode != 0 {
|
||||
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleHealth handles health check requests
|
||||
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status := map[string]any{
|
||||
|
|
|
|||
|
|
@ -170,7 +170,8 @@ type AgentDefaults struct {
|
|||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||
|
|
@ -179,6 +180,15 @@ type AgentDefaults struct {
|
|||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||
}
|
||||
|
||||
// GetModelName returns the effective model name for the agent defaults.
|
||||
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
|
||||
func (d *AgentDefaults) GetModelName() string {
|
||||
if d.ModelName != "" {
|
||||
return d.ModelName
|
||||
}
|
||||
return d.Model
|
||||
}
|
||||
|
||||
type ChannelsConfig struct {
|
||||
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
|
|
@ -443,6 +453,9 @@ type WebToolsConfig struct {
|
|||
Tavily TavilyConfig `json:"tavily"`
|
||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
||||
Perplexity PerplexityConfig `json:"perplexity"`
|
||||
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
|
||||
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||
}
|
||||
|
||||
type CronToolsConfig struct {
|
||||
|
|
@ -499,6 +512,20 @@ func LoadConfig(path string) (*Config, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Pre-scan the JSON to check how many model_list entries the user provided.
|
||||
// Go's JSON decoder reuses existing slice backing-array elements rather than
|
||||
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
|
||||
// would silently inherit values from the DefaultConfig template at the same
|
||||
// index position. We only reset cfg.ModelList when the user actually provides
|
||||
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
|
||||
var tmp Config
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tmp.ModelList) > 0 {
|
||||
cfg.ModelList = nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -392,3 +392,24 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
|
|||
t.Fatal("OpenAI codex web search should be false when disabled in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
configJSON := `{
|
||||
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
|
||||
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}],
|
||||
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
if cfg.Tools.Web.Proxy != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
|||
|
||||
// Get user's configured provider and model
|
||||
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||
userModel := cfg.Agents.Defaults.Model
|
||||
userModel := cfg.Agents.Defaults.GetModelName()
|
||||
|
||||
p := cfg.Providers
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
|
@ -114,6 +115,137 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaults AgentDefaults
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
name: "new model_name field only",
|
||||
defaults: AgentDefaults{ModelName: "new-model"},
|
||||
wantName: "new-model",
|
||||
},
|
||||
{
|
||||
name: "old model field only",
|
||||
defaults: AgentDefaults{Model: "legacy-model"},
|
||||
wantName: "legacy-model",
|
||||
},
|
||||
{
|
||||
name: "both fields - model_name takes precedence",
|
||||
defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"},
|
||||
wantName: "new-model",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.defaults.GetModelName(); got != tt.wantName {
|
||||
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
name: "new model_name field",
|
||||
json: `{"model_name": "gpt4"}`,
|
||||
wantName: "gpt4",
|
||||
},
|
||||
{
|
||||
name: "old model field",
|
||||
json: `{"model": "gpt4"}`,
|
||||
wantName: "gpt4",
|
||||
},
|
||||
{
|
||||
name: "both fields - model_name wins",
|
||||
json: `{"model_name": "new", "model": "old"}`,
|
||||
wantName: "new",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var defaults AgentDefaults
|
||||
if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
|
||||
t.Fatalf("Unmarshal error: %v", err)
|
||||
}
|
||||
if got := defaults.GetModelName(); got != tt.wantName {
|
||||
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullConfig_JSON_BackwardCompat(t *testing.T) {
|
||||
// Test complete config with both old and new formats
|
||||
oldFormat := `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "gpt4",
|
||||
"max_tokens": 4096
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-4o",
|
||||
"api_key": "test-key"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
newFormat := `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model_name": "gpt4",
|
||||
"max_tokens": 4096
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "gpt4",
|
||||
"model": "openai/gpt-4o",
|
||||
"api_key": "test-key"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
for name, jsonStr := range map[string]string{
|
||||
"old format (model)": oldFormat,
|
||||
"new format (model_name)": newFormat,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
|
||||
t.Fatalf("Unmarshal error: %v", err)
|
||||
}
|
||||
|
||||
// Check that GetModelName returns correct value
|
||||
if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" {
|
||||
t.Errorf("GetModelName() = %q, want %q", got, "gpt4")
|
||||
}
|
||||
|
||||
// Check that GetModelConfig works
|
||||
modelCfg, err := cfg.GetModelConfig("gpt4")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelConfig error: %v", err)
|
||||
}
|
||||
if modelCfg.Model != "openai/gpt-4o" {
|
||||
t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelConfig_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -35,9 +35,8 @@ var usbClassToCapability = map[string]string{
|
|||
}
|
||||
|
||||
type USBMonitor struct {
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewUSBMonitor() *USBMonitor {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,10 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
|
|||
|
||||
if agents, ok := getMap(data, "agents"); ok {
|
||||
if defaults, ok := getMap(agents, "defaults"); ok {
|
||||
if v, ok := getString(defaults, "model"); ok {
|
||||
// Prefer model_name, fallback to model for backward compatibility
|
||||
if v, ok := getString(defaults, "model_name"); ok {
|
||||
cfg.Agents.Defaults.ModelName = v
|
||||
} else if v, ok := getString(defaults, "model"); ok {
|
||||
cfg.Agents.Defaults.Model = v
|
||||
}
|
||||
if v, ok := getFloat(defaults, "max_tokens"); ok {
|
||||
|
|
|
|||
|
|
@ -404,64 +404,6 @@ type antigravityJSONResponse struct {
|
|||
} `json:"usageMetadata"`
|
||||
}
|
||||
|
||||
func (p *AntigravityProvider) parseJSONResponse(body []byte) (*LLMResponse, error) {
|
||||
var resp antigravityJSONResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("parsing antigravity response: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Candidates) == 0 {
|
||||
return nil, fmt.Errorf("antigravity: no candidates in response")
|
||||
}
|
||||
|
||||
candidate := resp.Candidates[0]
|
||||
var contentParts []string
|
||||
var toolCalls []ToolCall
|
||||
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.Text != "" {
|
||||
contentParts = append(contentParts, part.Text)
|
||||
}
|
||||
if part.FunctionCall != nil {
|
||||
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: part.FunctionCall.Args,
|
||||
Function: &FunctionCall{
|
||||
Name: part.FunctionCall.Name,
|
||||
Arguments: string(argumentsJSON),
|
||||
ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
if candidate.FinishReason == "MAX_TOKENS" {
|
||||
finishReason = "length"
|
||||
}
|
||||
|
||||
var usage *UsageInfo
|
||||
if resp.UsageMetadata.TotalTokenCount > 0 {
|
||||
usage = &UsageInfo{
|
||||
PromptTokens: resp.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: resp.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: strings.Join(contentParts, ""),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
|
||||
var contentParts []string
|
||||
var toolCalls []ToolCall
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ type providerSelection struct {
|
|||
}
|
||||
|
||||
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||
model := cfg.Agents.Defaults.Model
|
||||
model := cfg.Agents.Defaults.GetModelName()
|
||||
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||
lowerModel := strings.ToLower(model)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,12 +17,6 @@ func successRun(content string) func(ctx context.Context, provider, model string
|
|||
}
|
||||
}
|
||||
|
||||
func failRun(err error) func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||
return func(ctx context.Context, provider, model string) (*LLMResponse, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallback_SingleCandidate_Success(t *testing.T) {
|
||||
ct := NewCooldownTracker()
|
||||
fc := NewFallbackChain(ct)
|
||||
|
|
|
|||
|
|
@ -4,60 +4,84 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
copilot "github.com/github/copilot-sdk/go"
|
||||
)
|
||||
|
||||
type GitHubCopilotProvider struct {
|
||||
uri string
|
||||
connectMode string // `stdio` or `grpc``
|
||||
connectMode string // "stdio" or "grpc"
|
||||
|
||||
client *copilot.Client
|
||||
session *copilot.Session
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
|
||||
var session *copilot.Session
|
||||
if connectMode == "" {
|
||||
connectMode = "grpc"
|
||||
}
|
||||
switch connectMode {
|
||||
|
||||
switch connectMode {
|
||||
case "stdio":
|
||||
// todo
|
||||
// TODO:
|
||||
return nil, fmt.Errorf("stdio mode not implemented")
|
||||
case "grpc":
|
||||
client := copilot.NewClient(&copilot.ClientOptions{
|
||||
CLIUrl: uri,
|
||||
})
|
||||
if err := client.Start(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details",
|
||||
"can't connect to Github Copilot: %w; `https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server` for details",
|
||||
err,
|
||||
)
|
||||
}
|
||||
defer client.Stop()
|
||||
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
|
||||
|
||||
session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
|
||||
Model: model,
|
||||
Hooks: &copilot.SessionHooks{},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
client.Stop()
|
||||
return nil, fmt.Errorf("create session failed: %w", err)
|
||||
}
|
||||
|
||||
return &GitHubCopilotProvider{
|
||||
uri: uri,
|
||||
connectMode: connectMode,
|
||||
client: client,
|
||||
session: session,
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown connect mode: %s", connectMode)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GitHubCopilotProvider) Close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.client != nil {
|
||||
p.client.Stop()
|
||||
p.client = nil
|
||||
p.session = nil
|
||||
}
|
||||
|
||||
return &GitHubCopilotProvider{
|
||||
uri: uri,
|
||||
connectMode: connectMode,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Chat sends a chat request to GitHub Copilot
|
||||
func (p *GitHubCopilotProvider) Chat(
|
||||
ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
type tempMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
out := make([]tempMessage, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
out = append(out, tempMessage{
|
||||
Role: msg.Role,
|
||||
|
|
@ -65,12 +89,30 @@ func (p *GitHubCopilotProvider) Chat(
|
|||
})
|
||||
}
|
||||
|
||||
fullcontent, _ := json.Marshal(out)
|
||||
fullcontent, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal messages: %w", err)
|
||||
}
|
||||
p.mu.Lock()
|
||||
session := p.session
|
||||
p.mu.Unlock()
|
||||
|
||||
content, _ := p.session.Send(ctx, copilot.MessageOptions{
|
||||
if session == nil {
|
||||
return nil, fmt.Errorf("provider closed")
|
||||
}
|
||||
|
||||
resp, err := session.SendAndWait(ctx, copilot.MessageOptions{
|
||||
Prompt: string(fullcontent),
|
||||
})
|
||||
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("empty response from copilot")
|
||||
}
|
||||
if resp.Data.Content == nil {
|
||||
return nil, fmt.Errorf("no content in copilot response")
|
||||
}
|
||||
content := *resp.Data.Content
|
||||
|
||||
return &LLMResponse{
|
||||
FinishReason: "stop",
|
||||
Content: content,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
// The old providers config is automatically converted to model_list during config loading.
|
||||
// Returns the provider, the model ID to use, and any error.
|
||||
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
|
||||
model := cfg.Agents.Defaults.Model
|
||||
model := cfg.Agents.Defaults.GetModelName()
|
||||
|
||||
// Ensure model_list is populated (should be done by LoadConfig, but handle edge cases)
|
||||
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
|
||||
|
|
|
|||
|
|
@ -148,8 +148,9 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
|||
var apiResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function *struct {
|
||||
|
|
@ -221,10 +222,11 @@ func parseResponse(body []byte) (*LLMResponse, error) {
|
|||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: choice.Message.Content,
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: choice.FinishReason,
|
||||
Usage: apiResponse.Usage,
|
||||
Content: choice.Message.Content,
|
||||
ReasoningContent: choice.Message.ReasoningContent,
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: choice.FinishReason,
|
||||
Usage: apiResponse.Usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,50 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_ParsesReasoningContent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{
|
||||
"message": map[string]any{
|
||||
"content": "The answer is 2",
|
||||
"reasoning_content": "Let me think step by step... 1+1=2",
|
||||
"tool_calls": []map[string]any{
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": "calculator",
|
||||
"arguments": "{\"expr\":\"1+1\"}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "1+1=?"}}, nil, "kimi-k2.5", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if out.ReasoningContent != "Let me think step by step... 1+1=2" {
|
||||
t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think step by step... 1+1=2")
|
||||
}
|
||||
if out.Content != "The answer is 2" {
|
||||
t.Fatalf("Content = %q, want %q", out.Content, "The answer is 2")
|
||||
}
|
||||
if len(out.ToolCalls) != 1 {
|
||||
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_HTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ type FunctionCall struct {
|
|||
}
|
||||
|
||||
type LLMResponse struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Usage *UsageInfo `json:"usage,omitempty"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Usage *UsageInfo `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type UsageInfo struct {
|
||||
|
|
@ -38,10 +39,11 @@ type UsageInfo struct {
|
|||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
type ToolDefinition struct {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ type LLMProvider interface {
|
|||
GetDefaultModel() string
|
||||
}
|
||||
|
||||
type StatefulProvider interface {
|
||||
LLMProvider
|
||||
Close()
|
||||
}
|
||||
|
||||
// FailoverReason classifies why an LLM request failed for fallback decisions.
|
||||
type FailoverReason string
|
||||
|
||||
|
|
|
|||
|
|
@ -55,9 +55,9 @@ func (info SkillInfo) validate() error {
|
|||
|
||||
type SkillsLoader struct {
|
||||
workspace string
|
||||
workspaceSkills string // workspace skills (项目级别)
|
||||
globalSkills string // 全局 skills (~/.picoclaw/skills)
|
||||
builtinSkills string // 内置 skills
|
||||
workspaceSkills string // workspace skills (project-level)
|
||||
globalSkills string // global skills (~/.picoclaw/skills)
|
||||
builtinSkills string // builtin skills
|
||||
}
|
||||
|
||||
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
|
||||
|
|
@ -120,7 +120,7 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
|||
}
|
||||
|
||||
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
||||
// 1. 优先从 workspace skills 加载(项目级别)
|
||||
// 1. load from workspace skills first (project-level)
|
||||
if sl.workspaceSkills != "" {
|
||||
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
|
||||
if content, err := os.ReadFile(skillFile); err == nil {
|
||||
|
|
@ -128,7 +128,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
|||
}
|
||||
}
|
||||
|
||||
// 2. 其次从全局 skills 加载 (~/.picoclaw/skills)
|
||||
// 2. then load from global skills (~/.picoclaw/skills)
|
||||
if sl.globalSkills != "" {
|
||||
skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md")
|
||||
if content, err := os.ReadFile(skillFile); err == nil {
|
||||
|
|
@ -136,7 +136,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. 最后从内置 skills 加载
|
||||
// 3. finally load from builtin skills
|
||||
if sl.builtinSkills != "" {
|
||||
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
|
||||
if content, err := os.ReadFile(skillFile); err == nil {
|
||||
|
|
|
|||
|
|
@ -117,13 +117,19 @@ func (t *I2CTool) detect() *ToolResult {
|
|||
return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
|
||||
}
|
||||
|
||||
// Helper functions for I2C operations (used by platform-specific implementations)
|
||||
|
||||
// isValidBusID checks that a bus identifier is a simple number (prevents path injection)
|
||||
//
|
||||
//nolint:unused // Used by i2c_linux.go
|
||||
func isValidBusID(id string) bool {
|
||||
matched, _ := regexp.MatchString(`^\d+$`, id)
|
||||
return matched
|
||||
}
|
||||
|
||||
// parseI2CAddress extracts and validates an I2C address from args
|
||||
//
|
||||
//nolint:unused // Used by i2c_linux.go
|
||||
func parseI2CAddress(args map[string]any) (int, *ToolResult) {
|
||||
addrFloat, ok := args["address"].(float64)
|
||||
if !ok {
|
||||
|
|
@ -137,6 +143,8 @@ func parseI2CAddress(args map[string]any) (int, *ToolResult) {
|
|||
}
|
||||
|
||||
// parseI2CBus extracts and validates an I2C bus from args
|
||||
//
|
||||
//nolint:unused // Used by i2c_linux.go
|
||||
func parseI2CBus(args map[string]any) (string, *ToolResult) {
|
||||
bus, ok := args["bus"].(string)
|
||||
if !ok || bus == "" {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package tools
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SpawnTool struct {
|
||||
|
|
@ -66,8 +67,8 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
|
|||
|
||||
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
task, ok := args["task"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("task is required")
|
||||
if !ok || strings.TrimSpace(task) == "" {
|
||||
return ErrorResult("task is required and must be a non-empty string")
|
||||
}
|
||||
|
||||
label, _ := args["label"].(string)
|
||||
|
|
|
|||
79
pkg/tools/spawn_test.go
Normal file
79
pkg/tools/spawn_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
|
||||
provider := &MockLLMProvider{}
|
||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
||||
tool := NewSpawnTool(manager)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
}{
|
||||
{"empty string", map[string]any{"task": ""}},
|
||||
{"whitespace only", map[string]any{"task": " "}},
|
||||
{"tabs and newlines", map[string]any{"task": "\t\n "}},
|
||||
{"missing task key", map[string]any{"label": "test"}},
|
||||
{"wrong type", map[string]any{"task": 123}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tool.Execute(ctx, tt.args)
|
||||
if result == nil {
|
||||
t.Fatal("Result should not be nil")
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Error("Expected error for invalid task parameter")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "task is required") {
|
||||
t.Errorf("Error message should mention 'task is required', got: %s", result.ForLLM)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnTool_Execute_ValidTask(t *testing.T) {
|
||||
provider := &MockLLMProvider{}
|
||||
manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
|
||||
tool := NewSpawnTool(manager)
|
||||
|
||||
ctx := context.Background()
|
||||
args := map[string]any{
|
||||
"task": "Write a haiku about coding",
|
||||
"label": "haiku-task",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
if result == nil {
|
||||
t.Fatal("Result should not be nil")
|
||||
}
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success for valid task, got error: %s", result.ForLLM)
|
||||
}
|
||||
if !result.Async {
|
||||
t.Error("SpawnTool should return async result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnTool_Execute_NilManager(t *testing.T) {
|
||||
tool := NewSpawnTool(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
args := map[string]any{"task": "test task"}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
if !result.IsError {
|
||||
t.Error("Expected error for nil manager")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "Subagent manager not configured") {
|
||||
t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
|
@ -119,7 +119,11 @@ func (t *SPITool) list() *ToolResult {
|
|||
return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result)))
|
||||
}
|
||||
|
||||
// Helper function for SPI operations (used by platform-specific implementations)
|
||||
|
||||
// parseSPIArgs extracts and validates common SPI parameters
|
||||
//
|
||||
//nolint:unused // Used by spi_linux.go
|
||||
func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
|
||||
dev, ok := args["device"].(string)
|
||||
if !ok || dev == "" {
|
||||
|
|
|
|||
101
pkg/tools/web.go
101
pkg/tools/web.go
|
|
@ -17,12 +17,50 @@ const (
|
|||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
// createHTTPClient creates an HTTP client with optional proxy support
|
||||
func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableCompression: false,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
if proxyURL != "" {
|
||||
proxy, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy URL: %w", err)
|
||||
}
|
||||
scheme := strings.ToLower(proxy.Scheme)
|
||||
switch scheme {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)",
|
||||
proxy.Scheme,
|
||||
)
|
||||
}
|
||||
if proxy.Host == "" {
|
||||
return nil, fmt.Errorf("invalid proxy URL: missing host")
|
||||
}
|
||||
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
|
||||
} else {
|
||||
client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
type SearchProvider interface {
|
||||
Search(ctx context.Context, query string, count int) (string, error)
|
||||
}
|
||||
|
||||
type BraveSearchProvider struct {
|
||||
apiKey string
|
||||
proxy string
|
||||
}
|
||||
|
||||
func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||
|
|
@ -37,7 +75,10 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
|
|||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Subscription-Token", p.apiKey)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
client, err := createHTTPClient(p.proxy, 10*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
|
|
@ -167,7 +208,9 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
|
|||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
type DuckDuckGoSearchProvider struct{}
|
||||
type DuckDuckGoSearchProvider struct {
|
||||
proxy string
|
||||
}
|
||||
|
||||
func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
|
||||
|
|
@ -179,7 +222,10 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou
|
|||
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
client, err := createHTTPClient(p.proxy, 10*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
|
|
@ -261,6 +307,7 @@ func stripTags(content string) string {
|
|||
|
||||
type PerplexitySearchProvider struct {
|
||||
apiKey string
|
||||
proxy string
|
||||
}
|
||||
|
||||
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||
|
|
@ -295,7 +342,10 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
|
|||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
client, err := createHTTPClient(p.proxy, 30*time.Second)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create HTTP client: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
|
|
@ -348,6 +398,7 @@ type WebSearchToolOptions struct {
|
|||
PerplexityAPIKey string
|
||||
PerplexityMaxResults int
|
||||
PerplexityEnabled bool
|
||||
Proxy string
|
||||
}
|
||||
|
||||
func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
|
||||
|
|
@ -356,12 +407,12 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
|
|||
|
||||
// Priority: Perplexity > Brave > Tavily > DuckDuckGo
|
||||
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
|
||||
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey}
|
||||
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy}
|
||||
if opts.PerplexityMaxResults > 0 {
|
||||
maxResults = opts.PerplexityMaxResults
|
||||
}
|
||||
} else if opts.BraveEnabled && opts.BraveAPIKey != "" {
|
||||
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
|
||||
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy}
|
||||
if opts.BraveMaxResults > 0 {
|
||||
maxResults = opts.BraveMaxResults
|
||||
}
|
||||
|
|
@ -374,7 +425,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
|
|||
maxResults = opts.TavilyMaxResults
|
||||
}
|
||||
} else if opts.DuckDuckGoEnabled {
|
||||
provider = &DuckDuckGoSearchProvider{}
|
||||
provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy}
|
||||
if opts.DuckDuckGoMaxResults > 0 {
|
||||
maxResults = opts.DuckDuckGoMaxResults
|
||||
}
|
||||
|
|
@ -441,6 +492,7 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
|||
|
||||
type WebFetchTool struct {
|
||||
maxChars int
|
||||
proxy string
|
||||
}
|
||||
|
||||
func NewWebFetchTool(maxChars int) *WebFetchTool {
|
||||
|
|
@ -452,6 +504,16 @@ func NewWebFetchTool(maxChars int) *WebFetchTool {
|
|||
}
|
||||
}
|
||||
|
||||
func NewWebFetchToolWithProxy(maxChars int, proxy string) *WebFetchTool {
|
||||
if maxChars <= 0 {
|
||||
maxChars = 50000
|
||||
}
|
||||
return &WebFetchTool{
|
||||
maxChars: maxChars,
|
||||
proxy: proxy,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WebFetchTool) Name() string {
|
||||
return "web_fetch"
|
||||
}
|
||||
|
|
@ -511,20 +573,17 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
|||
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableCompression: false,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("stopped after 5 redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
client, err := createHTTPClient(t.proxy, 60*time.Second)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create HTTP client: %v", err))
|
||||
}
|
||||
|
||||
// Configure redirect handling
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("stopped after 5 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWebTool_WebFetch_Success verifies successful URL fetching
|
||||
|
|
@ -334,6 +335,172 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_ProxyConfigured(t *testing.T) {
|
||||
client, err := createHTTPClient("http://127.0.0.1:7890", 12*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
if client.Timeout != 12*time.Second {
|
||||
t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("transport.Proxy is nil, want non-nil")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest() error: %v", err)
|
||||
}
|
||||
proxyURL, err := tr.Proxy(req)
|
||||
if err != nil {
|
||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_InvalidProxy(t *testing.T) {
|
||||
_, err := createHTTPClient("://bad-proxy", 10*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) {
|
||||
client, err := createHTTPClient("socks5://127.0.0.1:1080", 8*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest() error: %v", err)
|
||||
}
|
||||
proxyURL, err := tr.Proxy(req)
|
||||
if err != nil {
|
||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
||||
}
|
||||
if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" {
|
||||
t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) {
|
||||
_, err := createHTTPClient("ftp://127.0.0.1:21", 10*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported proxy scheme") {
|
||||
t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) {
|
||||
t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888")
|
||||
t.Setenv("http_proxy", "http://127.0.0.1:8888")
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888")
|
||||
t.Setenv("https_proxy", "http://127.0.0.1:8888")
|
||||
t.Setenv("ALL_PROXY", "")
|
||||
t.Setenv("all_proxy", "")
|
||||
t.Setenv("NO_PROXY", "")
|
||||
t.Setenv("no_proxy", "")
|
||||
|
||||
client, err := createHTTPClient("", 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("createHTTPClient() error: %v", err)
|
||||
}
|
||||
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("transport.Proxy is nil, want proxy function from environment")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest() error: %v", err)
|
||||
}
|
||||
if _, err := tr.Proxy(req); err != nil {
|
||||
t.Fatalf("transport.Proxy(req) error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWebFetchToolWithProxy(t *testing.T) {
|
||||
tool := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890")
|
||||
if tool.maxChars != 1024 {
|
||||
t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024)
|
||||
}
|
||||
if tool.proxy != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890")
|
||||
}
|
||||
|
||||
tool = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890")
|
||||
if tool.maxChars != 50000 {
|
||||
t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
||||
t.Run("perplexity", func(t *testing.T) {
|
||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
||||
PerplexityEnabled: true,
|
||||
PerplexityAPIKey: "k",
|
||||
PerplexityMaxResults: 3,
|
||||
Proxy: "http://127.0.0.1:7890",
|
||||
})
|
||||
p, ok := tool.provider.(*PerplexitySearchProvider)
|
||||
if !ok {
|
||||
t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider)
|
||||
}
|
||||
if p.proxy != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("brave", func(t *testing.T) {
|
||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
||||
BraveEnabled: true,
|
||||
BraveAPIKey: "k",
|
||||
BraveMaxResults: 3,
|
||||
Proxy: "http://127.0.0.1:7890",
|
||||
})
|
||||
p, ok := tool.provider.(*BraveSearchProvider)
|
||||
if !ok {
|
||||
t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider)
|
||||
}
|
||||
if p.proxy != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duckduckgo", func(t *testing.T) {
|
||||
tool := NewWebSearchTool(WebSearchToolOptions{
|
||||
DuckDuckGoEnabled: true,
|
||||
DuckDuckGoMaxResults: 3,
|
||||
Proxy: "http://127.0.0.1:7890",
|
||||
})
|
||||
p, ok := tool.provider.(*DuckDuckGoSearchProvider)
|
||||
if !ok {
|
||||
t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider)
|
||||
}
|
||||
if p.proxy != "http://127.0.0.1:7890" {
|
||||
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWebTool_TavilySearch_Success verifies successful Tavily search
|
||||
func TestWebTool_TavilySearch_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue