release: v0.2.0

This commit is contained in:
lpf
2026-03-11 19:00:19 +08:00
parent 1c0e463d07
commit 13108b0333
104 changed files with 6519 additions and 4296 deletions

View File

@@ -9,6 +9,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -16,16 +17,16 @@ import (
)
type Config struct {
Agents AgentsConfig `json:"agents"`
Channels ChannelsConfig `json:"channels"`
Providers ProvidersConfig `json:"providers"`
Gateway GatewayConfig `json:"gateway"`
Cron CronConfig `json:"cron"`
Tools ToolsConfig `json:"tools"`
Logging LoggingConfig `json:"logging"`
Sentinel SentinelConfig `json:"sentinel"`
Memory MemoryConfig `json:"memory"`
mu sync.RWMutex
Agents AgentsConfig `json:"agents"`
Channels ChannelsConfig `json:"channels"`
Models ModelsConfig `json:"models,omitempty"`
Gateway GatewayConfig `json:"gateway"`
Cron CronConfig `json:"cron"`
Tools ToolsConfig `json:"tools"`
Logging LoggingConfig `json:"logging"`
Sentinel SentinelConfig `json:"sentinel"`
Memory MemoryConfig `json:"memory"`
mu sync.RWMutex
}
type AgentsConfig struct {
@@ -107,7 +108,7 @@ type SubagentToolsConfig struct {
}
type SubagentRuntimeConfig struct {
Proxy string `json:"proxy,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"`
@@ -120,8 +121,7 @@ type SubagentRuntimeConfig struct {
type AgentDefaults struct {
Workspace string `json:"workspace" env:"CLAWGO_AGENTS_DEFAULTS_WORKSPACE"`
Proxy string `json:"proxy" env:"CLAWGO_AGENTS_DEFAULTS_PROXY"`
ProxyFallbacks []string `json:"proxy_fallbacks" env:"CLAWGO_AGENTS_DEFAULTS_PROXY_FALLBACKS"`
Model AgentModelDefaults `json:"model,omitempty"`
MaxTokens int `json:"max_tokens" env:"CLAWGO_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature float64 `json:"temperature" env:"CLAWGO_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"CLAWGO_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
@@ -131,6 +131,11 @@ type AgentDefaults struct {
SummaryPolicy SystemSummaryPolicyConfig `json:"summary_policy"`
}
type AgentModelDefaults struct {
Primary string `json:"primary,omitempty" env:"CLAWGO_AGENTS_DEFAULTS_MODEL_PRIMARY"`
Fallbacks []string `json:"fallbacks,omitempty" env:"CLAWGO_AGENTS_DEFAULTS_MODEL_FALLBACKS"`
}
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_ENABLED"`
EverySec int `json:"every_sec" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_EVERY_SEC"`
@@ -234,52 +239,8 @@ type DingTalkConfig struct {
AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_DINGTALK_ALLOW_FROM"`
}
type ProvidersConfig struct {
Proxy ProviderConfig `json:"proxy"`
Proxies map[string]ProviderConfig `json:"proxies"`
}
type providerProxyItem struct {
Name string `json:"name"`
ProviderConfig
}
func (p *ProvidersConfig) UnmarshalJSON(data []byte) error {
var tmp struct {
Proxy ProviderConfig `json:"proxy"`
Proxies json.RawMessage `json:"proxies"`
}
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
p.Proxy = tmp.Proxy
p.Proxies = map[string]ProviderConfig{}
if len(bytes.TrimSpace(tmp.Proxies)) == 0 || string(bytes.TrimSpace(tmp.Proxies)) == "null" {
return nil
}
// Preferred format: object map
var asMap map[string]ProviderConfig
if err := json.Unmarshal(tmp.Proxies, &asMap); err == nil {
for k, v := range asMap {
if k == "" {
continue
}
p.Proxies[k] = v
}
return nil
}
// Compatibility format: array [{name, ...provider fields...}]
var asArr []providerProxyItem
if err := json.Unmarshal(tmp.Proxies, &asArr); err == nil {
for _, it := range asArr {
if it.Name == "" {
continue
}
p.Proxies[it.Name] = it.ProviderConfig
}
return nil
}
return fmt.Errorf("providers.proxies must be object map or array of {name,...}")
type ModelsConfig struct {
Providers map[string]ProviderConfig `json:"providers,omitempty"`
}
type ProviderConfig struct {
@@ -298,6 +259,7 @@ type ProviderConfig struct {
type ProviderOAuthConfig struct {
Provider string `json:"provider,omitempty"`
NetworkProxy string `json:"network_proxy,omitempty"`
CredentialFile string `json:"credential_file,omitempty"`
CredentialFiles []string `json:"credential_files,omitempty"`
CallbackPort int `json:"callback_port,omitempty"`
@@ -307,7 +269,6 @@ type ProviderOAuthConfig struct {
TokenURL string `json:"token_url,omitempty"`
RedirectURL string `json:"redirect_url,omitempty"`
Scopes []string `json:"scopes,omitempty"`
HybridPriority string `json:"hybrid_priority,omitempty"`
CooldownSec int `json:"cooldown_sec,omitempty"`
RefreshScanSec int `json:"refresh_scan_sec,omitempty"`
RefreshLeadSec int `json:"refresh_lead_sec,omitempty"`
@@ -484,8 +445,7 @@ func DefaultConfig() *Config {
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: filepath.Join(configDir, "workspace"),
Proxy: "proxy",
ProxyFallbacks: []string{},
Model: AgentModelDefaults{Primary: "openai/gpt-5.4", Fallbacks: []string{}},
MaxTokens: 8192,
Temperature: 0.7,
MaxToolIterations: 20,
@@ -599,13 +559,14 @@ func DefaultConfig() *Config {
AllowFrom: []string{},
},
},
Providers: ProvidersConfig{
Proxy: ProviderConfig{
APIBase: "http://localhost:8080/v1",
Models: []string{"glm-4.7"},
TimeoutSec: 90,
Models: ModelsConfig{
Providers: map[string]ProviderConfig{
"openai": {
APIBase: "https://api.openai.com/v1",
Models: []string{"gpt-5.4"},
TimeoutSec: 90,
},
},
Proxies: map[string]ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
@@ -699,6 +660,60 @@ func DefaultConfig() *Config {
}
}
func ParseProviderModelRef(raw string) (provider string, model string) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", ""
}
if idx := strings.Index(trimmed, "/"); idx > 0 {
return strings.TrimSpace(trimmed[:idx]), strings.TrimSpace(trimmed[idx+1:])
}
return "", trimmed
}
func AllProviderConfigs(cfg *Config) map[string]ProviderConfig {
out := map[string]ProviderConfig{}
if cfg == nil {
return out
}
for name, pc := range cfg.Models.Providers {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
out[trimmed] = pc
}
return out
}
func ProviderConfigByName(cfg *Config, name string) (ProviderConfig, bool) {
if cfg == nil {
return ProviderConfig{}, false
}
pc, ok := AllProviderConfigs(cfg)[strings.TrimSpace(name)]
return pc, ok
}
func ProviderExists(cfg *Config, name string) bool {
_, ok := ProviderConfigByName(cfg, name)
return ok
}
func PrimaryProviderName(cfg *Config) string {
if cfg == nil {
return "openai"
}
if provider, _ := ParseProviderModelRef(cfg.Agents.Defaults.Model.Primary); provider != "" {
return provider
}
for name := range cfg.Models.Providers {
if trimmed := strings.TrimSpace(name); trimmed != "" {
return trimmed
}
}
return "openai"
}
func generateGatewayToken() string {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
@@ -771,13 +786,19 @@ func (c *Config) WorkspacePath() string {
func (c *Config) GetAPIKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.Providers.Proxy.APIKey
if pc, ok := c.Models.Providers[PrimaryProviderName(c)]; ok {
return pc.APIKey
}
return ""
}
func (c *Config) GetAPIBase() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.Providers.Proxy.APIBase
if pc, ok := c.Models.Providers[PrimaryProviderName(c)]; ok {
return pc.APIBase
}
return ""
}
func (c *Config) LogFilePath() string {

View File

@@ -87,30 +87,33 @@ func Validate(cfg *Config) []error {
}
}
if len(cfg.Providers.Proxies) == 0 {
errs = append(errs, validateProviderConfig("providers.proxy", cfg.Providers.Proxy)...)
} else {
for name, p := range cfg.Providers.Proxies {
errs = append(errs, validateProviderConfig("providers.proxies."+name, p)...)
for name, p := range cfg.Models.Providers {
errs = append(errs, validateProviderConfig("models.providers."+name, p)...)
}
if len(cfg.Models.Providers) == 0 {
errs = append(errs, fmt.Errorf("models.providers must contain at least one provider"))
}
for _, name := range cfg.Agents.Defaults.Model.Fallbacks {
if !ProviderExists(cfg, name) {
errs = append(errs, fmt.Errorf("agents.defaults.model.fallbacks contains unknown provider %q", name))
}
}
if cfg.Agents.Defaults.Proxy != "" {
if !providerExists(cfg, cfg.Agents.Defaults.Proxy) {
errs = append(errs, fmt.Errorf("agents.defaults.proxy %q not found in providers", cfg.Agents.Defaults.Proxy))
if primaryRef := strings.TrimSpace(cfg.Agents.Defaults.Model.Primary); primaryRef != "" {
providerName, modelName := ParseProviderModelRef(primaryRef)
if providerName == "" {
providerName = PrimaryProviderName(cfg)
}
}
for _, name := range cfg.Agents.Defaults.ProxyFallbacks {
if !providerExists(cfg, name) {
errs = append(errs, fmt.Errorf("agents.defaults.proxy_fallbacks contains unknown proxy %q", name))
if !ProviderExists(cfg, providerName) {
errs = append(errs, fmt.Errorf("agents.defaults.model.primary %q references unknown provider %q", primaryRef, providerName))
}
if strings.TrimSpace(modelName) == "" {
errs = append(errs, fmt.Errorf("agents.defaults.model.primary must include a model, expected provider/model"))
}
}
if cfg.Agents.Defaults.ContextCompaction.Enabled && cfg.Agents.Defaults.ContextCompaction.Mode == "responses_compact" {
active := cfg.Agents.Defaults.Proxy
if active == "" {
active = "proxy"
}
if pc, ok := providerConfigByName(cfg, active); !ok || !pc.SupportsResponsesCompact {
errs = append(errs, fmt.Errorf("context_compaction.mode=responses_compact requires active proxy %q with supports_responses_compact=true", active))
active := PrimaryProviderName(cfg)
if pc, ok := ProviderConfigByName(cfg, active); !ok || !pc.SupportsResponsesCompact {
errs = append(errs, fmt.Errorf("context_compaction.mode=responses_compact requires active provider %q with supports_responses_compact=true", active))
}
}
errs = append(errs, validateAgentRouter(cfg)...)
@@ -474,8 +477,8 @@ func validateSubagents(cfg *Config) []error {
errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file must stay within workspace", id))
}
}
if proxy := strings.TrimSpace(raw.Runtime.Proxy); proxy != "" && !providerExists(cfg, proxy) {
errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.proxy %q not found in providers", id, proxy))
if provider := strings.TrimSpace(raw.Runtime.Provider); provider != "" && !ProviderExists(cfg, provider) {
errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.provider %q not found in providers", id, provider))
}
for _, sender := range raw.AcceptFrom {
sender = strings.TrimSpace(sender)
@@ -562,13 +565,6 @@ func validateProviderConfig(path string, p ProviderConfig) []error {
errs = append(errs, fmt.Errorf("%s.oauth.provider is required when auth=hybrid", path))
}
}
if p.OAuth.HybridPriority != "" {
switch strings.ToLower(strings.TrimSpace(p.OAuth.HybridPriority)) {
case "api_first", "oauth_first":
default:
errs = append(errs, fmt.Errorf("%s.oauth.hybrid_priority must be one of: api_first, oauth_first", path))
}
}
if p.OAuth.CooldownSec < 0 {
errs = append(errs, fmt.Errorf("%s.oauth.cooldown_sec must be >= 0", path))
}
@@ -587,25 +583,6 @@ func validateProviderConfig(path string, p ProviderConfig) []error {
return errs
}
func providerExists(cfg *Config, name string) bool {
if name == "proxy" && cfg.Providers.Proxy.APIBase != "" {
return true
}
if cfg.Providers.Proxies == nil {
return false
}
_, ok := cfg.Providers.Proxies[name]
return ok
}
func providerConfigByName(cfg *Config, name string) (ProviderConfig, bool) {
if strings.TrimSpace(name) == "proxy" {
return cfg.Providers.Proxy, true
}
pc, ok := cfg.Providers.Proxies[name]
return pc, ok
}
func validateNonEmptyStringList(path string, values []string) []error {
if len(values) == 0 {
return nil

View File

@@ -34,7 +34,7 @@ func TestValidateSubagentsAllowsKnownPeers(t *testing.T) {
AcceptFrom: []string{"main"},
CanTalkTo: []string{"main"},
Runtime: SubagentRuntimeConfig{
Proxy: "proxy",
Provider: "openai",
},
}
@@ -66,7 +66,7 @@ func TestValidateSubagentsRejectsAbsolutePromptFile(t *testing.T) {
Enabled: true,
SystemPromptFile: "/tmp/AGENT.md",
Runtime: SubagentRuntimeConfig{
Proxy: "proxy",
Provider: "openai",
},
}
@@ -82,7 +82,7 @@ func TestValidateSubagentsRequiresPromptFileWhenEnabled(t *testing.T) {
cfg.Agents.Subagents["coder"] = SubagentConfig{
Enabled: true,
Runtime: SubagentRuntimeConfig{
Proxy: "proxy",
Provider: "openai",
},
}
@@ -123,7 +123,7 @@ func TestValidateSubagentsRejectsInvalidNotifyMainPolicy(t *testing.T) {
SystemPromptFile: "agents/coder/AGENT.md",
NotifyMainPolicy: "loud",
Runtime: SubagentRuntimeConfig{
Proxy: "proxy",
Provider: "openai",
},
}
@@ -276,9 +276,11 @@ func TestValidateProviderOAuthAllowsEmptyModelsBeforeLogin(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Providers.Proxy.Auth = "oauth"
cfg.Providers.Proxy.Models = nil
cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{Provider: "codex"}
pc := cfg.Models.Providers["openai"]
pc.Auth = "oauth"
pc.Models = nil
pc.OAuth = ProviderOAuthConfig{Provider: "codex"}
cfg.Models.Providers["openai"] = pc
if errs := Validate(cfg); len(errs) != 0 {
t.Fatalf("expected oauth provider config to be valid before model sync, got %v", errs)
@@ -289,9 +291,11 @@ func TestValidateProviderOAuthRequiresProviderName(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Providers.Proxy.Auth = "oauth"
cfg.Providers.Proxy.Models = nil
cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{}
pc := cfg.Models.Providers["openai"]
pc.Auth = "oauth"
pc.Models = nil
pc.OAuth = ProviderOAuthConfig{}
cfg.Models.Providers["openai"] = pc
errs := Validate(cfg)
if len(errs) == 0 {
@@ -299,7 +303,7 @@ func TestValidateProviderOAuthRequiresProviderName(t *testing.T) {
}
found := false
for _, err := range errs {
if strings.Contains(err.Error(), "providers.proxy.oauth.provider") {
if strings.Contains(err.Error(), "models.providers.openai.oauth.provider") {
found = true
break
}
@@ -313,10 +317,12 @@ func TestValidateProviderHybridAllowsEmptyModels(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Providers.Proxy.Auth = "hybrid"
cfg.Providers.Proxy.APIKey = "sk-test"
cfg.Providers.Proxy.Models = nil
cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{Provider: "codex"}
pc := cfg.Models.Providers["openai"]
pc.Auth = "hybrid"
pc.APIKey = "sk-test"
pc.Models = nil
pc.OAuth = ProviderOAuthConfig{Provider: "codex"}
cfg.Models.Providers["openai"] = pc
if errs := Validate(cfg); len(errs) != 0 {
t.Fatalf("expected hybrid provider config to be valid before model sync, got %v", errs)
@@ -327,10 +333,12 @@ func TestValidateProviderHybridRequiresOAuthProvider(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Providers.Proxy.Auth = "hybrid"
cfg.Providers.Proxy.APIKey = "sk-test"
cfg.Providers.Proxy.Models = nil
cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{}
pc := cfg.Models.Providers["openai"]
pc.Auth = "hybrid"
pc.APIKey = "sk-test"
pc.Models = nil
pc.OAuth = ProviderOAuthConfig{}
cfg.Models.Providers["openai"] = pc
errs := Validate(cfg)
if len(errs) == 0 {
@@ -338,7 +346,7 @@ func TestValidateProviderHybridRequiresOAuthProvider(t *testing.T) {
}
found := false
for _, err := range errs {
if strings.Contains(err.Error(), "providers.proxy.oauth.provider") {
if strings.Contains(err.Error(), "models.providers.openai.oauth.provider") {
found = true
break
}
@@ -347,30 +355,3 @@ func TestValidateProviderHybridRequiresOAuthProvider(t *testing.T) {
t.Fatalf("expected oauth.provider validation error, got %v", errs)
}
}
func TestValidateProviderHybridPriorityRejectsInvalidValue(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Providers.Proxy.Auth = "hybrid"
cfg.Providers.Proxy.APIKey = "sk-test"
cfg.Providers.Proxy.OAuth = ProviderOAuthConfig{
Provider: "codex",
HybridPriority: "random_first",
}
errs := Validate(cfg)
if len(errs) == 0 {
t.Fatalf("expected validation errors")
}
found := false
for _, err := range errs {
if strings.Contains(err.Error(), "oauth.hybrid_priority") {
found = true
break
}
}
if !found {
t.Fatalf("expected oauth.hybrid_priority validation error, got %v", errs)
}
}