Files
lxowalle b3a7b7ad64 feat: agent self evolution (#2847)
* feat: add agent self-evolution

* fix ci

* delete unused doc

* fix lint

* fix evolution review issues
2026-05-11 16:13:27 +08:00

532 lines
15 KiB
Go

// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"encoding/json"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/logger"
)
// buildModelWithProtocol constructs a model string with protocol prefix.
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
// Otherwise, the protocol prefix is added.
func buildModelWithProtocol(protocol, model string) string {
if strings.Contains(model, "/") {
// Model already has a protocol prefix, return as-is
return model
}
return protocol + "/" + model
}
type legacyDiagnosticConfig struct {
Version int `json:"version"`
Isolation IsolationConfig `json:"isolation,omitempty"`
Agents legacyDiagnosticAgents `json:"agents,omitempty"`
Session SessionConfig `json:"session,omitempty"`
Channels map[string]any `json:"channels,omitempty"`
ChannelList ChannelsConfig `json:"channel_list,omitempty"`
ModelList []map[string]any `json:"model_list,omitempty"`
Gateway GatewayConfig `json:"gateway,omitempty"`
Hooks HooksConfig `json:"hooks,omitempty"`
Tools ToolsConfig `json:"tools,omitempty"`
Heartbeat HeartbeatConfig `json:"heartbeat,omitempty"`
Devices DevicesConfig `json:"devices,omitempty"`
Voice VoiceConfig `json:"voice,omitempty"`
Bindings json.RawMessage `json:"bindings,omitempty"`
Providers json.RawMessage `json:"providers,omitempty"`
}
type legacyDiagnosticAgents struct {
Defaults legacyDiagnosticAgentDefaults `json:"defaults,omitempty"`
List []AgentConfig `json:"list,omitempty"`
Dispatch *DispatchConfig `json:"dispatch,omitempty"`
}
type legacyDiagnosticAgentDefaults struct {
AgentDefaults
LegacyModel string `json:"model,omitempty"`
}
func validateLegacyConfigDiagnostics(data []byte) error {
var cfg legacyDiagnosticConfig
return decodeJSONWithDiagnostics(data, &cfg, "config.json")
}
func migrateLegacyAgentDefaultsModel(m map[string]any) {
agents, ok := m["agents"].(map[string]any)
if !ok {
return
}
defaults, ok := agents["defaults"].(map[string]any)
if !ok {
return
}
model, hasModel := defaults["model"]
if !hasModel {
return
}
if _, hasModelName := defaults["model_name"]; !hasModelName {
defaults["model_name"] = model
}
delete(defaults, "model")
}
// loadConfigV1 loads a version 1 config (current schema)
func loadConfig(data []byte) (*Config, error) {
cfg := DefaultConfig()
evolutionModeExplicit := configObjectHasField(data, "evolution", "mode")
evolutionExplicitWithoutMode := configObjectHasTopLevelField(data, "evolution") && !evolutionModeExplicit
// 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 := decodeJSONWithDiagnostics(data, &tmp, "config.json"); err != nil {
return nil, err
}
if len(tmp.ModelList) > 0 {
cfg.ModelList = nil
}
if err := decodeJSONWithDiagnostics(data, cfg, "config.json"); err != nil {
return nil, err
}
if evolutionExplicitWithoutMode {
cfg.Evolution.Mode = ""
}
return cfg, nil
}
func configObjectHasTopLevelField(data []byte, field string) bool {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return false
}
_, ok := raw[field]
return ok
}
func configObjectHasField(data []byte, objectField, nestedField string) bool {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return false
}
objectData, ok := raw[objectField]
if !ok {
return false
}
var object map[string]json.RawMessage
if err := json.Unmarshal(objectData, &object); err != nil {
return false
}
_, ok = object[nestedField]
return ok
}
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 compareInt(v any, expected int) bool {
switch val := v.(type) {
case int:
return val == expected
case float64:
return val == float64(expected)
case nil:
return expected == 0
default:
return false
}
}
// migrateV0ToV1 converts a V0 (legacy, no version field) config JSON to V1 format:
// 1. Migrates legacy providers to model_list
// 2. Migrates agents.defaults.model → agents.defaults.model_name
// 3. Sets version to 1
func migrateV0ToV1(m map[string]any) error {
if !compareInt(m["version"], 0) {
return fmt.Errorf("migrateV0ToV1: expected version 0, got %v", m["version"])
}
migrateLegacyAgentDefaultsModel(m)
// Migrate legacy providers to model_list if no model_list exists
if _, hasModelList := m["model_list"]; !hasModelList {
if providers, hasProviders := m["providers"]; hasProviders {
if provMap, ok := providers.(map[string]any); ok && !isProvidersMapEmpty(provMap) {
// Extract user's provider and model from agents.defaults
userProvider := ""
userModel := ""
if agents, ok := m["agents"].(map[string]any); ok {
if defaults, ok := agents["defaults"].(map[string]any); ok {
if v, ok := defaults["provider"].(string); ok {
userProvider = v
}
// Check both model_name (new) and model (old) fields
if v, ok := defaults["model_name"].(string); ok && v != "" {
userModel = v
} else if v, ok := defaults["model"].(string); ok && v != "" {
userModel = v
}
}
}
modelListRaw := v0ProvidersMapToModelList(provMap, userProvider, userModel)
if len(modelListRaw) > 0 {
m["model_list"] = modelListRaw
}
}
}
}
// Convert model_list api_key → api_keys
if modelList, ok := m["model_list"].([]any); ok {
for _, model := range modelList {
if mVal, ok := model.(map[string]any); ok {
if ss := toUniqueStrings(mVal["api_key"], mVal["api_keys"]); len(ss) > 0 {
mVal["api_keys"] = ss
delete(mVal, "api_key")
}
}
}
}
m["version"] = 1
return nil
}
func toUniqueStrings(s any, ss any) []string {
set := make(map[string]struct{})
// process s
if str, ok := s.(string); ok && str != "" {
set[str] = struct{}{}
}
// process ss as []any (JSON arrays)
if slice, ok := ss.([]any); ok {
for _, item := range slice {
if str, ok := item.(string); ok && str != "" {
set[str] = struct{}{}
}
}
}
// process ss as []string
if slice, ok := ss.([]string); ok {
for _, item := range slice {
if item != "" {
set[item] = struct{}{}
}
}
}
// map to slice
result := make([]string, 0, len(set))
for k := range set {
result = append(result, k)
}
return result
}
// migrateV1ToV2 converts a V1 config JSON to V2 format:
// 1. Migrates legacy "mention_only" to "group_trigger.mention_only"
// 2. Infers "enabled" field for models
// 3. Sets version to 2
func migrateV1ToV2(m map[string]any) error {
if !compareInt(m["version"], 1) {
return fmt.Errorf("migrateV1ToV2: expected version 1, got %#v", m["version"])
}
// Migrate channels: move "mention_only" to "group_trigger.mention_only"
if channels, ok := m["channels"]; ok {
if chMap, ok := channels.(map[string]any); ok {
for _, ch := range chMap {
if chVal, ok := ch.(map[string]any); ok {
if mentionOnly, hasMention := chVal["mention_only"]; hasMention {
delete(chVal, "mention_only")
if gt, hasGT := chVal["group_trigger"].(map[string]any); hasGT {
gt["mention_only"] = mentionOnly
} else {
chVal["group_trigger"] = map[string]any{"mention_only": mentionOnly}
}
}
}
}
}
}
// Infer "enabled" field for models matching configV1.migrateModelEnabled behavior
if modelList, ok := m["model_list"].([]any); ok {
// Convert api_key → api_keys for each model
for _, model := range modelList {
if mVal, ok := model.(map[string]any); ok {
if ss := toUniqueStrings(mVal["api_key"], mVal["api_keys"]); len(ss) > 0 {
mVal["api_keys"] = ss
delete(mVal, "api_key")
}
}
}
// Infer enabled status
for _, model := range modelList {
if mVal, ok := model.(map[string]any); ok {
// Skip if explicitly set
if _, hasEnabled := mVal["enabled"]; hasEnabled {
continue
}
// Models with API keys are considered enabled
if apiKeys, hasAPIKeys := mVal["api_keys"]; hasAPIKeys {
// Check for []any or []string
hasKeys := false
if keys, ok := apiKeys.([]any); ok {
hasKeys = len(keys) > 0
} else if keys, ok := apiKeys.([]string); ok {
hasKeys = len(keys) > 0
}
if hasKeys {
mVal["enabled"] = true
continue
}
}
// The reserved "local-model" entry is considered enabled
if mVal["model_name"] == "local-model" {
mVal["enabled"] = true
}
logger.Infof("model: %v", mVal)
}
}
} else {
logger.Warnf("model_list is not a slice: %#v", m["model_list"])
}
m["version"] = 2
return nil
}
// migrateV2ToV3 converts a V2 config JSON to V3 format:
// 1. Renames "channels" key to "channel_list"
// 2. Converts flat-format channel entries to nested format (wrapping
// channel-specific fields in "settings")
// 3. Sets version to 3
func migrateV2ToV3(m map[string]any) error {
if !compareInt(m["version"], 2) {
return fmt.Errorf("migrateV2ToV3: expected version 2, got %v", m["version"])
}
migrateLegacyAgentDefaultsModel(m)
delete(m, "bindings")
// Rename channels → channel_list
if channels, ok := m["channels"]; ok {
delete(m, "channels")
// Convert each channel from flat to nested format
if chMap, ok := channels.(map[string]any); ok {
for k, ch := range chMap {
if chVal, ok := ch.(map[string]any); ok {
chVal["type"] = k
// If already has "settings" key, leave as-is
if _, hasSettings := chVal["settings"]; hasSettings {
continue
}
// Migrate Onebot "group_trigger_prefix" → "group_trigger.prefixes"
if gtp, hasGTP := chVal["group_trigger_prefix"]; hasGTP {
if gt, hasGT := chVal["group_trigger"].(map[string]any); hasGT {
if _, hasPrefixes := gt["prefixes"]; !hasPrefixes {
gt["prefixes"] = gtp
}
} else {
chVal["group_trigger"] = map[string]any{"prefixes": gtp}
}
delete(chVal, "group_trigger_prefix")
}
// Separate channel-specific fields into "settings"
settings := make(map[string]any)
for fieldKey, v := range chVal {
if _, exists := BaseFieldNames[fieldKey]; !exists {
settings[fieldKey] = v
delete(chVal, fieldKey)
}
}
if len(settings) > 0 {
chVal["settings"] = settings
}
}
}
}
m["channel_list"] = channels
}
m["version"] = CurrentVersion
return nil
}
func loadConfigMap(path string) (map[string]any, error) {
var m1, m2 map[string]any
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return m1, nil
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
if err = json.Unmarshal(data, &m1); err != nil {
return nil, wrapJSONError(data, err, "config.json")
}
secPath := securityPath(path)
data, err = os.ReadFile(secPath)
if err != nil {
if os.IsNotExist(err) {
return m1, nil
}
return nil, fmt.Errorf("failed to read security config: %w", err)
}
if err = yaml.Unmarshal(data, &m2); err != nil {
return nil, fmt.Errorf("failed to parse security config: %w", err)
}
if m2["web"] != nil || m2["skills"] != nil {
m3 := make(map[string]any)
if m2["web"] != nil {
m3["web"] = m2["web"]
delete(m2, "web")
}
if m2["skills"] != nil {
m3["skills"] = m2["skills"]
delete(m2, "skills")
if m, ok := m3["skills"].(map[string]any); ok {
if m["clawhub"] != nil {
m["registries"] = map[string]any{"clawhub": m["clawhub"]}
delete(m, "clawhub")
}
if gh, ok := m["github"].(map[string]any); ok {
registries, _ := m["registries"].(map[string]any)
if registries == nil {
registries = map[string]any{}
}
githubRegistry := map[string]any{}
for k, v := range gh {
githubRegistry[k] = v
}
if token, ok := githubRegistry["token"]; ok {
githubRegistry["auth_token"] = token
}
registries["github"] = githubRegistry
m["registries"] = registries
}
}
}
m2["tools"] = m3
}
// Handle model_list merging specially: m1 has array format, m2 has map format
if mainML, hasMainML := m1["model_list"]; hasMainML {
if secML, hasSecML := m2["model_list"]; hasSecML {
if secMap, ok := secML.(map[string]any); ok {
// JSON unmarshals arrays as []any, convert to []map[string]any
var mainArr []any
if rawArr, ok := mainML.([]any); ok {
mainArr = make([]any, 0, len(rawArr))
for _, item := range rawArr {
if mVal, ok := item.(map[string]any); ok {
mainArr = append(mainArr, mVal)
}
}
}
if len(mainArr) > 0 {
// Merge array-style with map-style in-place
err = mergeModelListsWithMap(mainArr, secMap)
if err != nil {
logger.Errorf("mergeModelListsWithMap error: %v", err)
return nil, err
}
m1["model_list"] = mainArr
}
}
}
}
// Remove model_list from m2 so mergeMap doesn't override the array with map
delete(m2, "model_list")
m := mergeMap(m1, m2)
return m, nil
}
// mergeModelListsWithMap merges array-style model_list with map-style security model_list.
// It generates indexed keys from model_name (like toNameIndex) and uses them
// to look up security entries, falling back to ModelName if the indexed key doesn't exist.
func mergeModelListsWithMap(mainML []any, secML map[string]any) error {
// Build indexed keys like toNameIndex does
indexedKeys := make(map[string]int)
countMap := make(map[string]int)
for i, m := range mainML {
if mVal, ok := m.(map[string]any); ok {
if name, hasName := mVal["model_name"]; hasName {
nameStr := name.(string)
index := countMap[nameStr]
indexedKeys[fmt.Sprintf("%s:%d", nameStr, index)] = i
if _, ok := indexedKeys[nameStr]; !ok {
indexedKeys[nameStr] = i
}
countMap[nameStr]++
} else {
return fmt.Errorf("model_name is required: %#v", mVal)
}
}
}
for k, v := range secML {
if i, ok := indexedKeys[k]; ok {
if vv, ok := v.(map[string]any); ok {
if mVal, ok := mainML[i].(map[string]any); ok {
mVal["api_keys"] = vv["api_keys"]
}
}
} else {
logger.Warnf("model_name not found in main config: %s", k)
}
delete(secML, k)
}
return nil
}