mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 01:57:28 +08:00
549 lines
21 KiB
Go
549 lines
21 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Validate returns configuration problems found in cfg.
|
|
// It does not mutate cfg.
|
|
func Validate(cfg *Config) []error {
|
|
if cfg == nil {
|
|
return []error{fmt.Errorf("config is nil")}
|
|
}
|
|
|
|
var errs []error
|
|
|
|
if cfg.Agents.Defaults.MaxToolIterations <= 0 {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.max_tool_iterations must be > 0"))
|
|
}
|
|
exec := cfg.Agents.Defaults.Execution
|
|
if exec.RunStateTTLSeconds < 60 {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.execution.run_state_ttl_seconds must be >= 60"))
|
|
}
|
|
if exec.RunStateMax <= 0 {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.execution.run_state_max must be > 0"))
|
|
}
|
|
errs = append(errs, validateNonEmptyStringList("agents.defaults.execution.tool_parallel_safe_names", exec.ToolParallelSafeNames)...)
|
|
if exec.ToolMaxParallelCalls <= 0 {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.execution.tool_max_parallel_calls must be > 0"))
|
|
}
|
|
summary := cfg.Agents.Defaults.SummaryPolicy
|
|
if strings.TrimSpace(summary.Marker) == "" {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.summary_policy.marker must be non-empty"))
|
|
}
|
|
if strings.TrimSpace(summary.CompletedPrefix) == "" {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.summary_policy.completed_prefix must be non-empty"))
|
|
}
|
|
if strings.TrimSpace(summary.ChangesPrefix) == "" {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.summary_policy.changes_prefix must be non-empty"))
|
|
}
|
|
if strings.TrimSpace(summary.OutcomePrefix) == "" {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.summary_policy.outcome_prefix must be non-empty"))
|
|
}
|
|
if strings.TrimSpace(summary.CompletedTitle) == "" {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.summary_policy.completed_title must be non-empty"))
|
|
}
|
|
if strings.TrimSpace(summary.ChangesTitle) == "" {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.summary_policy.changes_title must be non-empty"))
|
|
}
|
|
if strings.TrimSpace(summary.OutcomesTitle) == "" {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.summary_policy.outcomes_title must be non-empty"))
|
|
}
|
|
hb := cfg.Agents.Defaults.Heartbeat
|
|
if hb.Enabled {
|
|
if hb.EverySec <= 0 {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.heartbeat.every_sec must be > 0 when enabled=true"))
|
|
}
|
|
if hb.AckMaxChars <= 0 {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.heartbeat.ack_max_chars must be > 0 when enabled=true"))
|
|
}
|
|
}
|
|
if cfg.Agents.Defaults.ContextCompaction.Enabled {
|
|
cc := cfg.Agents.Defaults.ContextCompaction
|
|
if cc.Mode != "" {
|
|
switch cc.Mode {
|
|
case "summary", "responses_compact", "hybrid":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("agents.defaults.context_compaction.mode must be one of: summary, responses_compact, hybrid"))
|
|
}
|
|
}
|
|
if cc.TriggerMessages <= 0 {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.context_compaction.trigger_messages must be > 0 when enabled=true"))
|
|
}
|
|
if cc.KeepRecentMessages <= 0 {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.context_compaction.keep_recent_messages must be > 0 when enabled=true"))
|
|
}
|
|
if cc.TriggerMessages > 0 && cc.KeepRecentMessages >= cc.TriggerMessages {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.context_compaction.keep_recent_messages must be < trigger_messages"))
|
|
}
|
|
if cc.MaxSummaryChars <= 0 {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.context_compaction.max_summary_chars must be > 0 when enabled=true"))
|
|
}
|
|
if cc.MaxTranscriptChars <= 0 {
|
|
errs = append(errs, fmt.Errorf("agents.defaults.context_compaction.max_transcript_chars must be > 0 when enabled=true"))
|
|
}
|
|
}
|
|
|
|
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"))
|
|
}
|
|
if primaryRef := strings.TrimSpace(cfg.Agents.Defaults.Model.Primary); primaryRef != "" {
|
|
providerName, modelName := ParseProviderModelRef(primaryRef)
|
|
if providerName == "" {
|
|
providerName = PrimaryProviderName(cfg)
|
|
}
|
|
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 := 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)...)
|
|
errs = append(errs, validateAgentCommunication(cfg)...)
|
|
errs = append(errs, validateSubagents(cfg)...)
|
|
|
|
if cfg.Gateway.Port <= 0 || cfg.Gateway.Port > 65535 {
|
|
errs = append(errs, fmt.Errorf("gateway.port must be in 1..65535"))
|
|
}
|
|
if cfg.Cron.MinSleepSec <= 0 {
|
|
errs = append(errs, fmt.Errorf("cron.min_sleep_sec must be > 0"))
|
|
}
|
|
if cfg.Cron.MaxSleepSec <= 0 {
|
|
errs = append(errs, fmt.Errorf("cron.max_sleep_sec must be > 0"))
|
|
}
|
|
if cfg.Cron.MinSleepSec > cfg.Cron.MaxSleepSec {
|
|
errs = append(errs, fmt.Errorf("cron.min_sleep_sec must be <= cron.max_sleep_sec"))
|
|
}
|
|
if cfg.Cron.RetryBackoffBaseSec <= 0 {
|
|
errs = append(errs, fmt.Errorf("cron.retry_backoff_base_sec must be > 0"))
|
|
}
|
|
if cfg.Cron.RetryBackoffMaxSec <= 0 {
|
|
errs = append(errs, fmt.Errorf("cron.retry_backoff_max_sec must be > 0"))
|
|
}
|
|
if cfg.Cron.RetryBackoffBaseSec > cfg.Cron.RetryBackoffMaxSec {
|
|
errs = append(errs, fmt.Errorf("cron.retry_backoff_base_sec must be <= cron.retry_backoff_max_sec"))
|
|
}
|
|
if cfg.Cron.MaxConsecutiveFailureRetries < 0 {
|
|
errs = append(errs, fmt.Errorf("cron.max_consecutive_failure_retries must be >= 0"))
|
|
}
|
|
if cfg.Cron.MaxWorkers <= 0 {
|
|
errs = append(errs, fmt.Errorf("cron.max_workers must be > 0"))
|
|
}
|
|
|
|
if cfg.Logging.Enabled {
|
|
if cfg.Logging.Dir == "" {
|
|
errs = append(errs, fmt.Errorf("logging.dir is required when logging.enabled=true"))
|
|
}
|
|
if cfg.Logging.Filename == "" {
|
|
errs = append(errs, fmt.Errorf("logging.filename is required when logging.enabled=true"))
|
|
}
|
|
if cfg.Logging.MaxSizeMB <= 0 {
|
|
errs = append(errs, fmt.Errorf("logging.max_size_mb must be > 0"))
|
|
}
|
|
if cfg.Logging.RetentionDays <= 0 {
|
|
errs = append(errs, fmt.Errorf("logging.retention_days must be > 0"))
|
|
}
|
|
}
|
|
|
|
if cfg.Sentinel.Enabled && cfg.Sentinel.IntervalSec <= 0 {
|
|
errs = append(errs, fmt.Errorf("sentinel.interval_sec must be > 0 when sentinel.enabled=true"))
|
|
}
|
|
if raw := strings.TrimSpace(cfg.Sentinel.WebhookURL); raw != "" {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u == nil || u.Host == "" {
|
|
errs = append(errs, fmt.Errorf("sentinel.webhook_url must be a valid http/https URL"))
|
|
} else {
|
|
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
|
case "http", "https":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("sentinel.webhook_url must use http or https"))
|
|
}
|
|
}
|
|
}
|
|
if cfg.Memory.RecentDays <= 0 {
|
|
errs = append(errs, fmt.Errorf("memory.recent_days must be > 0"))
|
|
}
|
|
errs = append(errs, validateMCPTools(cfg)...)
|
|
|
|
if cfg.Channels.InboundMessageIDDedupeTTLSeconds <= 0 {
|
|
errs = append(errs, fmt.Errorf("channels.inbound_message_id_dedupe_ttl_seconds must be > 0"))
|
|
}
|
|
if cfg.Channels.InboundContentDedupeWindowSeconds <= 0 {
|
|
errs = append(errs, fmt.Errorf("channels.inbound_content_dedupe_window_seconds must be > 0"))
|
|
}
|
|
if cfg.Channels.OutboundDedupeWindowSeconds <= 0 {
|
|
errs = append(errs, fmt.Errorf("channels.outbound_dedupe_window_seconds must be > 0"))
|
|
}
|
|
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" {
|
|
errs = append(errs, fmt.Errorf("channels.telegram.token is required when channels.telegram.enabled=true"))
|
|
}
|
|
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" {
|
|
errs = append(errs, fmt.Errorf("channels.discord.token is required when channels.discord.enabled=true"))
|
|
}
|
|
if cfg.Channels.DingTalk.Enabled {
|
|
if cfg.Channels.DingTalk.ClientID == "" {
|
|
errs = append(errs, fmt.Errorf("channels.dingtalk.client_id is required when channels.dingtalk.enabled=true"))
|
|
}
|
|
if cfg.Channels.DingTalk.ClientSecret == "" {
|
|
errs = append(errs, fmt.Errorf("channels.dingtalk.client_secret is required when channels.dingtalk.enabled=true"))
|
|
}
|
|
}
|
|
if cfg.Channels.Feishu.Enabled {
|
|
if cfg.Channels.Feishu.AppID == "" {
|
|
errs = append(errs, fmt.Errorf("channels.feishu.app_id is required when channels.feishu.enabled=true"))
|
|
}
|
|
if cfg.Channels.Feishu.AppSecret == "" {
|
|
errs = append(errs, fmt.Errorf("channels.feishu.app_secret is required when channels.feishu.enabled=true"))
|
|
}
|
|
}
|
|
if cfg.Channels.QQ.Enabled {
|
|
if cfg.Channels.QQ.AppID == "" {
|
|
errs = append(errs, fmt.Errorf("channels.qq.app_id is required when channels.qq.enabled=true"))
|
|
}
|
|
if cfg.Channels.QQ.AppSecret == "" {
|
|
errs = append(errs, fmt.Errorf("channels.qq.app_secret is required when channels.qq.enabled=true"))
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func validateDispatchTagMap(prefix string, mapping map[string][]string) []error {
|
|
if len(mapping) == 0 {
|
|
return nil
|
|
}
|
|
errs := make([]error, 0)
|
|
for key, tags := range mapping {
|
|
if strings.TrimSpace(key) == "" {
|
|
errs = append(errs, fmt.Errorf("%s contains empty key", prefix))
|
|
continue
|
|
}
|
|
errs = append(errs, validateNonEmptyStringList(fmt.Sprintf("%s.%s", prefix, key), tags)...)
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func validateMCPTools(cfg *Config) []error {
|
|
var errs []error
|
|
mcp := cfg.Tools.MCP
|
|
if !mcp.Enabled {
|
|
return errs
|
|
}
|
|
if mcp.RequestTimeoutSec <= 0 {
|
|
errs = append(errs, fmt.Errorf("tools.mcp.request_timeout_sec must be > 0 when tools.mcp.enabled=true"))
|
|
}
|
|
for name, server := range mcp.Servers {
|
|
if strings.TrimSpace(name) == "" {
|
|
errs = append(errs, fmt.Errorf("tools.mcp.servers contains an empty server name"))
|
|
continue
|
|
}
|
|
if !server.Enabled {
|
|
continue
|
|
}
|
|
transport := strings.ToLower(strings.TrimSpace(server.Transport))
|
|
if transport == "" {
|
|
transport = "stdio"
|
|
}
|
|
if transport != "stdio" && transport != "http" && transport != "streamable_http" && transport != "sse" {
|
|
errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.transport must be one of: stdio, http, streamable_http, sse", name))
|
|
}
|
|
if transport == "stdio" && strings.TrimSpace(server.Command) == "" {
|
|
errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.command is required when transport=stdio", name))
|
|
}
|
|
if (transport == "http" || transport == "streamable_http" || transport == "sse") && strings.TrimSpace(server.URL) == "" {
|
|
errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.url is required when transport=%s", name, transport))
|
|
}
|
|
permission := strings.ToLower(strings.TrimSpace(server.Permission))
|
|
if permission == "" {
|
|
permission = "workspace"
|
|
}
|
|
if permission != "workspace" && permission != "full" {
|
|
errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.permission must be one of: workspace, full", name))
|
|
}
|
|
if transport == "stdio" {
|
|
if wd := strings.TrimSpace(server.WorkingDir); wd != "" {
|
|
if permission == "full" {
|
|
if !filepath.IsAbs(wd) {
|
|
errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.working_dir must be an absolute path when permission=full", name))
|
|
}
|
|
} else if filepath.IsAbs(wd) {
|
|
workspace := cfg.WorkspacePath()
|
|
rel, err := filepath.Rel(workspace, wd)
|
|
if err != nil || strings.HasPrefix(rel, "..") {
|
|
errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.working_dir must stay within workspace unless permission=full", name))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func validateAgentRouter(cfg *Config) []error {
|
|
router := cfg.Agents.Router
|
|
var errs []error
|
|
if router.Policy.IntentMaxInputChars < 200 {
|
|
errs = append(errs, fmt.Errorf("agents.router.policy.intent_max_input_chars must be >= 200"))
|
|
}
|
|
if router.Policy.MaxRoundsWithoutUser <= 0 {
|
|
errs = append(errs, fmt.Errorf("agents.router.policy.max_rounds_without_user must be > 0"))
|
|
}
|
|
if strings.TrimSpace(router.Strategy) != "" {
|
|
switch strings.TrimSpace(router.Strategy) {
|
|
case "rules_first", "round_robin", "manual":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("agents.router.strategy must be one of: rules_first, round_robin, manual"))
|
|
}
|
|
}
|
|
if router.MaxHops < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.router.max_hops must be >= 0"))
|
|
}
|
|
if router.DefaultTimeoutSec < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.router.default_timeout_sec must be >= 0"))
|
|
}
|
|
if router.Enabled && strings.TrimSpace(router.MainAgentID) == "" {
|
|
errs = append(errs, fmt.Errorf("agents.router.main_agent_id is required when agents.router.enabled=true"))
|
|
}
|
|
for i, rule := range router.Rules {
|
|
agentID := strings.TrimSpace(rule.AgentID)
|
|
if agentID == "" {
|
|
errs = append(errs, fmt.Errorf("agents.router.rules[%d].agent_id is required", i))
|
|
continue
|
|
}
|
|
if _, ok := cfg.Agents.Subagents[agentID]; !ok {
|
|
errs = append(errs, fmt.Errorf("agents.router.rules[%d].agent_id %q not found in agents.subagents", i, agentID))
|
|
}
|
|
if len(rule.Keywords) == 0 {
|
|
errs = append(errs, fmt.Errorf("agents.router.rules[%d].keywords must not be empty", i))
|
|
}
|
|
for _, kw := range rule.Keywords {
|
|
if strings.TrimSpace(kw) == "" {
|
|
errs = append(errs, fmt.Errorf("agents.router.rules[%d].keywords must not contain empty values", i))
|
|
}
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func validateAgentCommunication(cfg *Config) []error {
|
|
comm := cfg.Agents.Communication
|
|
var errs []error
|
|
if strings.TrimSpace(comm.Mode) != "" {
|
|
switch strings.TrimSpace(comm.Mode) {
|
|
case "mediated", "direct":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("agents.communication.mode must be one of: mediated, direct"))
|
|
}
|
|
}
|
|
if comm.MaxMessagesPerThread < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.communication.max_messages_per_thread must be >= 0"))
|
|
}
|
|
if comm.DefaultMessageTTLSec < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.communication.default_message_ttl_sec must be >= 0"))
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func validateSubagents(cfg *Config) []error {
|
|
var errs []error
|
|
if len(cfg.Agents.Subagents) == 0 {
|
|
return errs
|
|
}
|
|
mainID := strings.TrimSpace(cfg.Agents.Router.MainAgentID)
|
|
if cfg.Agents.Router.Enabled && mainID != "" {
|
|
if _, ok := cfg.Agents.Subagents[mainID]; !ok {
|
|
errs = append(errs, fmt.Errorf("agents.router.main_agent_id %q not found in agents.subagents", mainID))
|
|
}
|
|
}
|
|
for agentID, raw := range cfg.Agents.Subagents {
|
|
id := strings.TrimSpace(agentID)
|
|
if id == "" {
|
|
errs = append(errs, fmt.Errorf("agents.subagents contains an empty agent id"))
|
|
continue
|
|
}
|
|
if strings.TrimSpace(raw.Type) != "" {
|
|
switch strings.TrimSpace(raw.Type) {
|
|
case "router", "worker", "reviewer", "observer":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.type must be one of: router, worker, reviewer, observer", id))
|
|
}
|
|
}
|
|
transport := strings.TrimSpace(raw.Transport)
|
|
if transport != "" {
|
|
switch transport {
|
|
case "local":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.transport must be one of: local", id))
|
|
}
|
|
}
|
|
if policy := strings.TrimSpace(raw.NotifyMainPolicy); policy != "" {
|
|
switch policy {
|
|
case "final_only", "milestone", "on_blocked", "always", "internal_only":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.notify_main_policy must be one of: final_only, milestone, on_blocked, always, internal_only", id))
|
|
}
|
|
}
|
|
if raw.Runtime.TimeoutSec < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.timeout_sec must be >= 0", id))
|
|
}
|
|
if raw.Runtime.MaxRetries < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_retries must be >= 0", id))
|
|
}
|
|
if raw.Runtime.RetryBackoffMs < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.retry_backoff_ms must be >= 0", id))
|
|
}
|
|
if raw.Runtime.MaxTaskChars < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_task_chars must be >= 0", id))
|
|
}
|
|
if raw.Runtime.MaxResultChars < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_result_chars must be >= 0", id))
|
|
}
|
|
if raw.Runtime.MaxParallelRuns < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.max_parallel_runs must be >= 0", id))
|
|
}
|
|
if raw.Tools.MaxParallelCalls < 0 {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.tools.max_parallel_calls must be >= 0", id))
|
|
}
|
|
if raw.Enabled && strings.TrimSpace(raw.SystemPromptFile) == "" {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file is required when enabled=true", id))
|
|
}
|
|
if promptFile := strings.TrimSpace(raw.SystemPromptFile); promptFile != "" {
|
|
if filepath.IsAbs(promptFile) {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file must be relative", id))
|
|
}
|
|
if cleaned := filepath.Clean(promptFile); strings.HasPrefix(cleaned, "..") {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file must stay within workspace", id))
|
|
}
|
|
}
|
|
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)
|
|
if sender == "" {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.accept_from must not contain empty values", id))
|
|
continue
|
|
}
|
|
if sender != "user" && sender != id {
|
|
if _, ok := cfg.Agents.Subagents[sender]; !ok {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.accept_from references unknown agent %q", id, sender))
|
|
}
|
|
}
|
|
}
|
|
for _, target := range raw.CanTalkTo {
|
|
target = strings.TrimSpace(target)
|
|
if target == "" {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.can_talk_to must not contain empty values", id))
|
|
continue
|
|
}
|
|
if target != "user" {
|
|
if _, ok := cfg.Agents.Subagents[target]; !ok {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.can_talk_to references unknown agent %q", id, target))
|
|
}
|
|
}
|
|
}
|
|
if raw.RequiresMainMediation && mainID != "" && id == mainID {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.requires_main_mediation must be false for main agent", id))
|
|
}
|
|
}
|
|
for agentID, raw := range cfg.Agents.Subagents {
|
|
id := strings.TrimSpace(agentID)
|
|
for _, target := range raw.CanTalkTo {
|
|
target = strings.TrimSpace(target)
|
|
if target == "" || target == "user" {
|
|
continue
|
|
}
|
|
peer, ok := cfg.Agents.Subagents[target]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !containsString(raw.AcceptFrom, target) && !containsString(peer.AcceptFrom, id) {
|
|
errs = append(errs, fmt.Errorf("agents.subagents.%s.can_talk_to %q is not reciprocated by accept_from", id, target))
|
|
}
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func containsString(items []string, target string) bool {
|
|
target = strings.TrimSpace(target)
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item) == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validateProviderConfig(path string, p ProviderConfig) []error {
|
|
var errs []error
|
|
authMode := strings.ToLower(strings.TrimSpace(p.Auth))
|
|
if p.APIBase == "" {
|
|
errs = append(errs, fmt.Errorf("%s.api_base is required", path))
|
|
}
|
|
if p.TimeoutSec <= 0 {
|
|
errs = append(errs, fmt.Errorf("%s.timeout_sec must be > 0", path))
|
|
}
|
|
switch authMode {
|
|
case "", "bearer", "oauth", "none", "hybrid":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("%s.auth must be one of: bearer, oauth, hybrid, none", path))
|
|
}
|
|
if len(p.Models) == 0 && authMode != "oauth" && authMode != "hybrid" {
|
|
errs = append(errs, fmt.Errorf("%s.models must contain at least one model", path))
|
|
}
|
|
if authMode == "oauth" && strings.TrimSpace(p.OAuth.Provider) == "" {
|
|
errs = append(errs, fmt.Errorf("%s.oauth.provider is required when auth=oauth", path))
|
|
}
|
|
if authMode == "hybrid" {
|
|
if strings.TrimSpace(p.APIKey) == "" && strings.TrimSpace(p.OAuth.Provider) == "" {
|
|
errs = append(errs, fmt.Errorf("%s.hybrid auth requires api_key or oauth.provider", path))
|
|
}
|
|
if strings.TrimSpace(p.OAuth.Provider) == "" {
|
|
errs = append(errs, fmt.Errorf("%s.oauth.provider is required when auth=hybrid", path))
|
|
}
|
|
}
|
|
if p.OAuth.CooldownSec < 0 {
|
|
errs = append(errs, fmt.Errorf("%s.oauth.cooldown_sec must be >= 0", path))
|
|
}
|
|
if p.Responses.WebSearchContextSize != "" {
|
|
switch p.Responses.WebSearchContextSize {
|
|
case "low", "medium", "high":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("%s.responses.web_search_context_size must be one of: low, medium, high", path))
|
|
}
|
|
}
|
|
if p.Responses.FileSearchMaxNumResults < 0 {
|
|
errs = append(errs, fmt.Errorf("%s.responses.file_search_max_num_results must be >= 0", path))
|
|
}
|
|
errs = append(errs, validateNonEmptyStringList(path+".responses.file_search_vector_store_ids", p.Responses.FileSearchVectorStoreIDs)...)
|
|
errs = append(errs, validateNonEmptyStringList(path+".responses.include", p.Responses.Include)...)
|
|
return errs
|
|
}
|
|
|
|
func validateNonEmptyStringList(path string, values []string) []error {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
var errs []error
|
|
for i, value := range values {
|
|
if strings.TrimSpace(value) == "" {
|
|
errs = append(errs, fmt.Errorf("%s[%d] must not be empty", path, i))
|
|
}
|
|
}
|
|
return errs
|
|
}
|