Add multi-agent config and registry runtime flow

This commit is contained in:
lpf
2026-03-06 12:47:05 +08:00
parent 959870e6f7
commit 6902f65c54
29 changed files with 4654 additions and 76 deletions

View File

@@ -27,7 +27,70 @@ type Config struct {
}
type AgentsConfig struct {
Defaults AgentDefaults `json:"defaults"`
Defaults AgentDefaults `json:"defaults"`
Router AgentRouterConfig `json:"router,omitempty"`
Communication AgentCommunicationConfig `json:"communication,omitempty"`
Subagents map[string]SubagentConfig `json:"subagents,omitempty"`
}
type AgentRouterConfig struct {
Enabled bool `json:"enabled"`
MainAgentID string `json:"main_agent_id,omitempty"`
Strategy string `json:"strategy,omitempty"`
Rules []AgentRouteRule `json:"rules,omitempty"`
AllowDirectAgentChat bool `json:"allow_direct_agent_chat,omitempty"`
MaxHops int `json:"max_hops,omitempty"`
DefaultTimeoutSec int `json:"default_timeout_sec,omitempty"`
DefaultWaitReply bool `json:"default_wait_reply,omitempty"`
StickyThreadOwner bool `json:"sticky_thread_owner,omitempty"`
}
type AgentRouteRule struct {
AgentID string `json:"agent_id"`
Keywords []string `json:"keywords,omitempty"`
}
type AgentCommunicationConfig struct {
Mode string `json:"mode,omitempty"`
PersistThreads bool `json:"persist_threads,omitempty"`
PersistMessages bool `json:"persist_messages,omitempty"`
MaxMessagesPerThread int `json:"max_messages_per_thread,omitempty"`
DeadLetterQueue bool `json:"dead_letter_queue,omitempty"`
DefaultMessageTTLSec int `json:"default_message_ttl_sec,omitempty"`
}
type SubagentConfig struct {
Enabled bool `json:"enabled"`
Type string `json:"type,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Role string `json:"role,omitempty"`
Description string `json:"description,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
MemoryNamespace string `json:"memory_namespace,omitempty"`
AcceptFrom []string `json:"accept_from,omitempty"`
CanTalkTo []string `json:"can_talk_to,omitempty"`
RequiresMainMediation bool `json:"requires_main_mediation,omitempty"`
DefaultReplyTo string `json:"default_reply_to,omitempty"`
Tools SubagentToolsConfig `json:"tools,omitempty"`
Runtime SubagentRuntimeConfig `json:"runtime,omitempty"`
}
type SubagentToolsConfig struct {
Allowlist []string `json:"allowlist,omitempty"`
Denylist []string `json:"denylist,omitempty"`
MaxParallelCalls int `json:"max_parallel_calls,omitempty"`
}
type SubagentRuntimeConfig struct {
Proxy string `json:"proxy,omitempty"`
Model string `json:"model,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
RetryBackoffMs int `json:"retry_backoff_ms,omitempty"`
MaxTaskChars int `json:"max_task_chars,omitempty"`
MaxResultChars int `json:"max_result_chars,omitempty"`
MaxParallelRuns int `json:"max_parallel_runs,omitempty"`
}
type AgentDefaults struct {
@@ -352,6 +415,26 @@ func DefaultConfig() *Config {
},
},
},
Router: AgentRouterConfig{
Enabled: false,
MainAgentID: "main",
Strategy: "rules_first",
Rules: []AgentRouteRule{},
AllowDirectAgentChat: false,
MaxHops: 6,
DefaultTimeoutSec: 600,
DefaultWaitReply: true,
StickyThreadOwner: true,
},
Communication: AgentCommunicationConfig{
Mode: "mediated",
PersistThreads: true,
PersistMessages: true,
MaxMessagesPerThread: 100,
DeadLetterQueue: true,
DefaultMessageTTLSec: 86400,
},
Subagents: map[string]SubagentConfig{},
},
Channels: ChannelsConfig{
InboundMessageIDDedupeTTLSeconds: 600,

View File

@@ -116,6 +116,9 @@ func Validate(cfg *Config) []error {
errs = append(errs, fmt.Errorf("context_compaction.mode=responses_compact requires active proxy %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"))
@@ -213,6 +216,170 @@ func Validate(cfg *Config) []error {
return errs
}
func validateAgentRouter(cfg *Config) []error {
router := cfg.Agents.Router
var errs []error
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))
}
}
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 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))
}
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
if p.APIBase == "" {

View File

@@ -0,0 +1,44 @@
package config
import "testing"
func TestValidateSubagentsAllowsKnownPeers(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Agents.Router.Enabled = true
cfg.Agents.Router.MainAgentID = "main"
cfg.Agents.Subagents["main"] = SubagentConfig{
Enabled: true,
Type: "router",
AcceptFrom: []string{"user", "coder"},
CanTalkTo: []string{"coder"},
}
cfg.Agents.Subagents["coder"] = SubagentConfig{
Enabled: true,
Type: "worker",
AcceptFrom: []string{"main"},
CanTalkTo: []string{"main"},
Runtime: SubagentRuntimeConfig{
Proxy: "proxy",
},
}
if errs := Validate(cfg); len(errs) != 0 {
t.Fatalf("expected config to be valid, got %v", errs)
}
}
func TestValidateSubagentsRejectsUnknownPeer(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Agents.Subagents["coder"] = SubagentConfig{
Enabled: true,
AcceptFrom: []string{"main"},
}
if errs := Validate(cfg); len(errs) == 0 {
t.Fatalf("expected validation errors")
}
}