Merge branch 'upstream-main' into feat/subturn-poc

This commit is contained in:
Administrator
2026-03-18 22:57:01 +08:00
117 changed files with 14857 additions and 7091 deletions
+18 -5
View File
@@ -312,6 +312,7 @@ type TelegramConfig struct {
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
}
type FeishuConfig struct {
@@ -532,6 +533,7 @@ type ProvidersConfig struct {
Minimax ProviderConfig `json:"minimax"`
LongCat ProviderConfig `json:"longcat"`
ModelScope ProviderConfig `json:"modelscope"`
Novita ProviderConfig `json:"novita"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@@ -560,7 +562,8 @@ func (p ProvidersConfig) IsEmpty() bool {
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
p.ModelScope.APIKey == "" && p.ModelScope.APIBase == ""
p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" &&
p.Novita.APIKey == "" && p.Novita.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
@@ -590,7 +593,9 @@ type OpenAIProviderConfig struct {
// ModelConfig represents a model-centric provider configuration.
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
// The model field uses protocol prefix format: [protocol/]model-identifier
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
// Supported protocols include openai, anthropic, antigravity, claude-cli,
// codex-cli, github-copilot, and named OpenAI-compatible protocols such as
// groq, deepseek, modelscope, and novita.
// Default protocol is "openai" if no prefix is specified.
type ModelConfig struct {
// Required fields
@@ -694,10 +699,18 @@ type WebToolsConfig struct {
Perplexity PerplexityConfig ` json:"perplexity"`
SearXNG SearXNGConfig ` json:"searxng"`
GLMSearch GLMSearchConfig ` json:"glm_search"`
// PreferNative controls whether to use provider-native web search when
// the active LLM supports it (e.g. OpenAI web_search_preview). When true,
// the client-side web_search tool is hidden to avoid duplicate search surfaces,
// and the provider's built-in search is used instead. Falls back to client-side
// search when the provider does not support native search.
PreferNative bool `json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"`
// 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"`
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
}
type CronToolsConfig struct {
@@ -1030,7 +1043,7 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
}
// Multiple configs - use round-robin for load balancing
idx := rrCounter.Add(1) % uint64(len(matches))
idx := (rrCounter.Add(1) - 1) % uint64(len(matches))
return &matches[idx], nil
}
+55
View File
@@ -77,6 +77,22 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
}
}
func TestProvidersConfig_IsEmpty(t *testing.T) {
var empty ProvidersConfig
if !empty.IsEmpty() {
t.Fatal("empty ProvidersConfig should report empty")
}
novita := ProvidersConfig{
Novita: ProviderConfig{
APIKey: "test-key",
},
}
if novita.IsEmpty() {
t.Fatal("ProvidersConfig with novita settings should not report empty")
}
}
func TestAgentConfig_FullParse(t *testing.T) {
jsonData := `{
"agents": {
@@ -401,6 +417,45 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
}
}
func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Tools.Web.PreferNative {
t.Fatal("DefaultConfig().Tools.Web.PreferNative should be true")
}
}
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"enabled":true}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if !cfg.Tools.Web.PreferNative {
t.Fatal("PreferNative should remain true when unset in config file")
}
}
func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"prefer_native":false}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Tools.Web.PreferNative {
t.Fatal("PreferNative should be false when disabled in config file")
}
}
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Tools.Exec.AllowRemote {
+4 -1
View File
@@ -15,7 +15,7 @@ func DefaultConfig() *Config {
// Determine the base path for the workspace.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
var homePath string
if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" {
if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" {
homePath = picoclawHome
} else {
userHome, _ := os.UserHomeDir()
@@ -59,6 +59,7 @@ func DefaultConfig() *Config {
Enabled: true,
Text: "Thinking... 💭",
},
UseMarkdownV2: false,
},
Feishu: FeishuConfig{
Enabled: false,
@@ -412,8 +413,10 @@ func DefaultConfig() *Config {
ToolConfig: ToolConfig{
Enabled: true,
},
PreferNative: true,
Proxy: "",
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
Format: "plaintext",
Brave: BraveConfig{
Enabled: false,
APIKey: "",
+37
View File
@@ -0,0 +1,37 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
// Runtime environment variable keys for the picoclaw process.
// These control the location of files and binaries at runtime and are read
// directly via os.Getenv / os.LookupEnv. All picoclaw-specific keys use the
// PICOCLAW_ prefix. Reference these constants instead of inline string
// literals to keep all supported knobs visible in one place and to prevent
// typos.
const (
// EnvHome overrides the base directory for all picoclaw data
// (config, workspace, skills, auth store, …).
// Default: ~/.picoclaw
EnvHome = "PICOCLAW_HOME"
// EnvConfig overrides the full path to the JSON config file.
// Default: $PICOCLAW_HOME/config.json
EnvConfig = "PICOCLAW_CONFIG"
// EnvBuiltinSkills overrides the directory from which built-in
// skills are loaded.
// Default: <cwd>/skills
EnvBuiltinSkills = "PICOCLAW_BUILTIN_SKILLS"
// EnvBinary overrides the path to the picoclaw executable.
// Used by the web launcher when spawning the gateway subprocess.
// Default: resolved from the same directory as the current executable.
EnvBinary = "PICOCLAW_BINARY"
// EnvGatewayHost overrides the host address for the gateway server.
// Default: "127.0.0.1"
EnvGatewayHost = "PICOCLAW_GATEWAY_HOST"
)
+30
View File
@@ -80,6 +80,36 @@ func TestGetModelConfig_RoundRobin(t *testing.T) {
}
}
func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) {
rrCounter.Store(0)
cfg := &Config{
ModelList: []ModelConfig{
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
{ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"},
},
}
wantOrder := []string{
"openai/gpt-4o-1",
"openai/gpt-4o-2",
"openai/gpt-4o-3",
"openai/gpt-4o-1",
"openai/gpt-4o-2",
}
for i, want := range wantOrder {
result, err := cfg.GetModelConfig("lb-model")
if err != nil {
t.Fatalf("GetModelConfig() call %d error = %v", i, err)
}
if result.Model != want {
t.Fatalf("GetModelConfig() call %d model = %q, want %q", i, result.Model, want)
}
}
}
func TestGetModelConfig_Concurrent(t *testing.T) {
cfg := &Config{
ModelList: []ModelConfig{