diff --git a/agent/load.go b/agent/load.go index a5714cd1..2cdfed35 100644 --- a/agent/load.go +++ b/agent/load.go @@ -10,6 +10,7 @@ import ( "github.com/yaoapp/yao/agent/assistant" "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/i18n" + robottypes "github.com/yaoapp/yao/agent/robot/types" searchDefaults "github.com/yaoapp/yao/agent/search/defaults" searchTypes "github.com/yaoapp/yao/agent/search/types" storeMongo "github.com/yaoapp/yao/agent/store/mongo" @@ -72,6 +73,15 @@ func Load(cfg config.Config) error { agentDSL = &setting + // Register global phase agent resolver for robot pipeline. + // Robot executor falls back to this when no per-robot override is configured. + robottypes.GlobalPhaseAgentResolver = func(phase robottypes.Phase) string { + if agentDSL == nil || agentDSL.Uses == nil { + return "" + } + return agentDSL.Uses.GetPhaseAgent(string(phase)) + } + // Store Setting err = initStore() if err != nil { @@ -477,6 +487,13 @@ func resolveEnvStrings(setting *types.DSL) { setting.Uses.Keyword = helper.EnvString(setting.Uses.Keyword) setting.Uses.QueryDSL = helper.EnvString(setting.Uses.QueryDSL) setting.Uses.Rerank = helper.EnvString(setting.Uses.Rerank) + setting.Uses.Inspiration = helper.EnvString(setting.Uses.Inspiration) + setting.Uses.Goals = helper.EnvString(setting.Uses.Goals) + setting.Uses.Tasks = helper.EnvString(setting.Uses.Tasks) + setting.Uses.Delivery = helper.EnvString(setting.Uses.Delivery) + setting.Uses.Learning = helper.EnvString(setting.Uses.Learning) + setting.Uses.Host = helper.EnvString(setting.Uses.Host) + setting.Uses.Validation = helper.EnvString(setting.Uses.Validation) } setting.Cache = helper.EnvString(setting.Cache) diff --git a/agent/robot/executor/standard/delivery.go b/agent/robot/executor/standard/delivery.go index 23dfde0a..ffe624b9 100644 --- a/agent/robot/executor/standard/delivery.go +++ b/agent/robot/executor/standard/delivery.go @@ -28,9 +28,10 @@ func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Executi locale := getEffectiveLocale(robot, exec.Input) e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "generating_delivery")) - agentID := "__yao.delivery" - if robot.Config != nil && robot.Config.Resources != nil { - agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseDelivery) + // Get agent ID for delivery phase (per-robot config > global Uses > empty) + agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseDelivery) + if agentID == "" { + return fmt.Errorf("no Delivery Agent configured (set uses.delivery in agent.yml or resources.phases in robot config)") } formatter := NewInputFormatter() diff --git a/agent/robot/executor/standard/goals.go b/agent/robot/executor/standard/goals.go index 2b9a2fcb..bf014ccb 100644 --- a/agent/robot/executor/standard/goals.go +++ b/agent/robot/executor/standard/goals.go @@ -32,10 +32,10 @@ func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution, locale := getEffectiveLocale(robot, exec.Input) e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "planning_goals")) - // Get agent ID for goals phase - agentID := "__yao.goals" // default - if robot.Config != nil && robot.Config.Resources != nil { - agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseGoals) + // Get agent ID for goals phase (per-robot config > global Uses > empty) + agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseGoals) + if agentID == "" { + return fmt.Errorf("no Goals Agent configured (set uses.goals in agent.yml or resources.phases in robot config)") } // Build prompt based on trigger type diff --git a/agent/robot/executor/standard/host.go b/agent/robot/executor/standard/host.go index 67cb9eac..dd9aa9fc 100644 --- a/agent/robot/executor/standard/host.go +++ b/agent/robot/executor/standard/host.go @@ -18,12 +18,10 @@ func (e *Executor) CallHostAgent(ctx *robottypes.Context, robot *robottypes.Robo return nil, fmt.Errorf("robot cannot be nil") } - agentID := "" - if robot.Config != nil && robot.Config.Resources != nil { - agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseHost) - } + // Get agent ID for host phase (per-robot config > global Uses > empty) + agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseHost) if agentID == "" { - return nil, fmt.Errorf("no Host Agent configured for robot %s", robot.MemberID) + return nil, fmt.Errorf("no Host Agent configured for robot %s (set uses.host in agent.yml or resources.phases in robot config)", robot.MemberID) } inputJSON, err := json.Marshal(input) diff --git a/agent/robot/executor/standard/host_test.go b/agent/robot/executor/standard/host_test.go index a3ea8950..cdb542b3 100644 --- a/agent/robot/executor/standard/host_test.go +++ b/agent/robot/executor/standard/host_test.go @@ -31,6 +31,11 @@ func TestCallHostAgent_NilRobot(t *testing.T) { // H2: no Host Agent configured func TestCallHostAgent_NoHostAgent(t *testing.T) { + // Temporarily clear the global resolver so no fallback is available + orig := robottypes.GlobalPhaseAgentResolver + robottypes.GlobalPhaseAgentResolver = nil + defer func() { robottypes.GlobalPhaseAgentResolver = orig }() + e := standard.New() ctx := robottypes.NewContext(context.Background(), nil) diff --git a/agent/robot/executor/standard/inspiration.go b/agent/robot/executor/standard/inspiration.go index 9c0b06cc..8b1e235e 100644 --- a/agent/robot/executor/standard/inspiration.go +++ b/agent/robot/executor/standard/inspiration.go @@ -35,10 +35,10 @@ func (e *Executor) RunInspiration(ctx *robottypes.Context, exec *robottypes.Exec clock = robottypes.NewClockContext(time.Now(), "") } - // Get agent ID for inspiration phase - agentID := "__yao.inspiration" // default - if robot.Config != nil && robot.Config.Resources != nil { - agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseInspiration) + // Get agent ID for inspiration phase (per-robot config > global Uses > empty) + agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseInspiration) + if agentID == "" { + return fmt.Errorf("no Inspiration Agent configured (set uses.inspiration in agent.yml or resources.phases in robot config)") } // Build prompt using InputFormatter diff --git a/agent/robot/executor/standard/inspiration_test.go b/agent/robot/executor/standard/inspiration_test.go index c10834b0..5d37fbb1 100644 --- a/agent/robot/executor/standard/inspiration_test.go +++ b/agent/robot/executor/standard/inspiration_test.go @@ -215,13 +215,13 @@ func TestRunInspirationWithDefaultAgent(t *testing.T) { ctx := types.NewContext(context.Background(), testAuth()) - t.Run("uses default agent when not configured", func(t *testing.T) { + t.Run("uses global Uses config when per-robot resources not set", func(t *testing.T) { robot := &types.Robot{ MemberID: "test-robot-1", TeamID: "test-team-1", Config: &types.Config{ Identity: &types.Identity{Role: "Test Robot"}, - // No Resources configured - should use default __yao.inspiration + // No Resources configured — falls back to global uses.inspiration }, } exec := createTestExecution(robot, types.TriggerClock) @@ -229,9 +229,8 @@ func TestRunInspirationWithDefaultAgent(t *testing.T) { e := standard.New() err := e.RunInspiration(ctx, exec, nil) - // This will fail if __yao.inspiration doesn't exist - // In test environment, we expect it to fail with "agent not found" - // In production, it would use the default agent + // With global Uses configured (agent.yml: uses.inspiration = "robot.inspiration"), + // the call should succeed. Without global config, it would error. if err != nil { assert.Contains(t, err.Error(), "call failed") } diff --git a/agent/robot/executor/standard/tasks.go b/agent/robot/executor/standard/tasks.go index a0418bfd..9cb2a16d 100644 --- a/agent/robot/executor/standard/tasks.go +++ b/agent/robot/executor/standard/tasks.go @@ -37,10 +37,10 @@ func (e *Executor) RunTasks(ctx *robottypes.Context, exec *robottypes.Execution, return fmt.Errorf("goals not available for task planning") } - // Get agent ID for tasks phase - agentID := "__yao.tasks" // default - if robot.Config != nil && robot.Config.Resources != nil { - agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseTasks) + // Get agent ID for tasks phase (per-robot config > global Uses > empty) + agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseTasks) + if agentID == "" { + return fmt.Errorf("no Tasks Agent configured (set uses.tasks in agent.yml or resources.phases in robot config)") } // Build prompt with goals and available resources diff --git a/agent/robot/executor/standard/validator.go b/agent/robot/executor/standard/validator.go index fc08cfd4..d1b9ab7a 100644 --- a/agent/robot/executor/standard/validator.go +++ b/agent/robot/executor/standard/validator.go @@ -418,11 +418,13 @@ func (v *Validator) hasAgentRules(rules []string) bool { // validateSemantic performs semantic validation using the Validation Agent func (v *Validator) validateSemantic(task *robottypes.Task, output interface{}) *robottypes.ValidationResult { - // Get validation agent ID - validationAgentID := "__yao.validation" // default - if v.robot.Config != nil && v.robot.Config.Resources != nil { - if customID, ok := v.robot.Config.Resources.Phases["validation"]; ok && customID != "" { - validationAgentID = customID + // Get validation agent ID (per-robot config > global Uses > empty) + validationAgentID := robottypes.ResolvePhaseAgent(v.robot.Config, "validation") + if validationAgentID == "" { + return &robottypes.ValidationResult{ + Passed: false, + Score: 0, + Issues: []string{"no Validation Agent configured (set uses.validation in agent.yml or resources.phases in robot config)"}, } } diff --git a/agent/robot/types/config.go b/agent/robot/types/config.go index 585e3193..fc1fdaf7 100644 --- a/agent/robot/types/config.go +++ b/agent/robot/types/config.go @@ -258,14 +258,47 @@ type Resources struct { MCP []MCPConfig `json:"mcp,omitempty"` } -// GetPhaseAgent returns agent ID for phase (default: __yao.{phase}) +// GlobalPhaseAgentResolver is called by GetPhaseAgent when no per-robot override +// is configured. Set by the agent package at init time to read from Uses config. +// Returns empty string if the phase has no global default. +var GlobalPhaseAgentResolver func(phase Phase) string + +// GetPhaseAgent returns agent ID for a pipeline phase. +// Priority: per-robot Resources.Phases > global Uses config > empty string. func (r *Resources) GetPhaseAgent(phase Phase) string { if r != nil && r.Phases != nil { if id, ok := r.Phases[phase]; ok && id != "" { return id } } - return "__yao." + string(phase) + if GlobalPhaseAgentResolver != nil { + return GlobalPhaseAgentResolver(phase) + } + return "" +} + +// ResolvePhaseAgent resolves the agent ID for a phase from robot config. +// It delegates to Resources.GetPhaseAgent which handles the full priority chain: +// per-robot Resources.Phases > GlobalPhaseAgentResolver (Uses config) > empty. +// The phase parameter accepts both Phase type and raw string (e.g. "validation"). +func ResolvePhaseAgent(config *Config, phase interface{}) string { + var p Phase + switch v := phase.(type) { + case Phase: + p = v + case string: + p = Phase(v) + default: + return "" + } + + if config != nil && config.Resources != nil { + return config.Resources.GetPhaseAgent(p) + } + if GlobalPhaseAgentResolver != nil { + return GlobalPhaseAgentResolver(p) + } + return "" } // MCPConfig - MCP server configuration diff --git a/agent/robot/types/config_test.go b/agent/robot/types/config_test.go index 53a061b3..b1fb0f3f 100644 --- a/agent/robot/types/config_test.go +++ b/agent/robot/types/config_test.go @@ -216,18 +216,26 @@ func TestQuotaDefaults(t *testing.T) { } func TestResourcesGetPhaseAgent(t *testing.T) { - t.Run("nil resources - returns default", func(t *testing.T) { + t.Run("nil resources without global resolver - returns empty", func(t *testing.T) { + orig := types.GlobalPhaseAgentResolver + types.GlobalPhaseAgentResolver = nil + defer func() { types.GlobalPhaseAgentResolver = orig }() + var resources *types.Resources agent := resources.GetPhaseAgent(types.PhaseGoals) - assert.Equal(t, "__yao.goals", agent) + assert.Equal(t, "", agent) }) - t.Run("phase not configured - returns default", func(t *testing.T) { + t.Run("phase not configured without global resolver - returns empty", func(t *testing.T) { + orig := types.GlobalPhaseAgentResolver + types.GlobalPhaseAgentResolver = nil + defer func() { types.GlobalPhaseAgentResolver = orig }() + resources := &types.Resources{ Phases: map[types.Phase]string{}, } agent := resources.GetPhaseAgent(types.PhaseGoals) - assert.Equal(t, "__yao.goals", agent) + assert.Equal(t, "", agent) }) t.Run("custom phase agent", func(t *testing.T) { @@ -240,14 +248,80 @@ func TestResourcesGetPhaseAgent(t *testing.T) { assert.Equal(t, "custom.goals.agent", agent) }) - t.Run("all phases default names", func(t *testing.T) { + t.Run("global resolver fallback", func(t *testing.T) { + orig := types.GlobalPhaseAgentResolver + types.GlobalPhaseAgentResolver = func(phase types.Phase) string { + return "global." + string(phase) + } + defer func() { types.GlobalPhaseAgentResolver = orig }() + resources := &types.Resources{} - assert.Equal(t, "__yao.inspiration", resources.GetPhaseAgent(types.PhaseInspiration)) - assert.Equal(t, "__yao.goals", resources.GetPhaseAgent(types.PhaseGoals)) - assert.Equal(t, "__yao.tasks", resources.GetPhaseAgent(types.PhaseTasks)) - assert.Equal(t, "__yao.run", resources.GetPhaseAgent(types.PhaseRun)) - assert.Equal(t, "__yao.delivery", resources.GetPhaseAgent(types.PhaseDelivery)) - assert.Equal(t, "__yao.learning", resources.GetPhaseAgent(types.PhaseLearning)) + assert.Equal(t, "global.inspiration", resources.GetPhaseAgent(types.PhaseInspiration)) + assert.Equal(t, "global.goals", resources.GetPhaseAgent(types.PhaseGoals)) + assert.Equal(t, "global.tasks", resources.GetPhaseAgent(types.PhaseTasks)) + assert.Equal(t, "global.run", resources.GetPhaseAgent(types.PhaseRun)) + assert.Equal(t, "global.delivery", resources.GetPhaseAgent(types.PhaseDelivery)) + assert.Equal(t, "global.learning", resources.GetPhaseAgent(types.PhaseLearning)) + }) + + t.Run("per-robot override takes precedence over global resolver", func(t *testing.T) { + orig := types.GlobalPhaseAgentResolver + types.GlobalPhaseAgentResolver = func(phase types.Phase) string { + return "global." + string(phase) + } + defer func() { types.GlobalPhaseAgentResolver = orig }() + + resources := &types.Resources{ + Phases: map[types.Phase]string{ + types.PhaseGoals: "my-app.goals", + }, + } + assert.Equal(t, "my-app.goals", resources.GetPhaseAgent(types.PhaseGoals)) + assert.Equal(t, "global.inspiration", resources.GetPhaseAgent(types.PhaseInspiration)) + }) +} + +func TestResolvePhaseAgent(t *testing.T) { + t.Run("nil config without global resolver", func(t *testing.T) { + orig := types.GlobalPhaseAgentResolver + types.GlobalPhaseAgentResolver = nil + defer func() { types.GlobalPhaseAgentResolver = orig }() + + assert.Equal(t, "", types.ResolvePhaseAgent(nil, types.PhaseGoals)) + }) + + t.Run("nil config with global resolver", func(t *testing.T) { + orig := types.GlobalPhaseAgentResolver + types.GlobalPhaseAgentResolver = func(phase types.Phase) string { + return "global." + string(phase) + } + defer func() { types.GlobalPhaseAgentResolver = orig }() + + assert.Equal(t, "global.goals", types.ResolvePhaseAgent(nil, types.PhaseGoals)) + }) + + t.Run("config with resources override", func(t *testing.T) { + config := &types.Config{ + Resources: &types.Resources{ + Phases: map[types.Phase]string{ + types.PhaseDelivery: "app.delivery", + }, + }, + } + assert.Equal(t, "app.delivery", types.ResolvePhaseAgent(config, types.PhaseDelivery)) + }) + + t.Run("string phase argument", func(t *testing.T) { + orig := types.GlobalPhaseAgentResolver + types.GlobalPhaseAgentResolver = func(phase types.Phase) string { + if phase == "validation" { + return "app.validation" + } + return "" + } + defer func() { types.GlobalPhaseAgentResolver = orig }() + + assert.Equal(t, "app.validation", types.ResolvePhaseAgent(nil, "validation")) }) } diff --git a/agent/types/types.go b/agent/types/types.go index cf01e7ca..57d015c6 100644 --- a/agent/types/types.go +++ b/agent/types/types.go @@ -52,6 +52,42 @@ type Uses struct { Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Keyword extraction: "builtin", "", "mcp:." QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // QueryDSL generation: "builtin", "", "mcp:." Rerank string `json:"rerank,omitempty" yaml:"rerank,omitempty"` // Result reranking: "builtin", "", "mcp:." + + // Robot pipeline phase agents (application-level, not bundled as system agents) + // Empty means no default — must be configured per-robot via resources.phases or here globally. + Inspiration string `json:"inspiration,omitempty" yaml:"inspiration,omitempty"` // P0: Inspiration phase agent + Goals string `json:"goals,omitempty" yaml:"goals,omitempty"` // P1: Goals planning agent + Tasks string `json:"tasks,omitempty" yaml:"tasks,omitempty"` // P2: Task breakdown agent + Delivery string `json:"delivery,omitempty" yaml:"delivery,omitempty"` // P4: Delivery composition agent + Learning string `json:"learning,omitempty" yaml:"learning,omitempty"` // P5: Learning extraction agent + Host string `json:"host,omitempty" yaml:"host,omitempty"` // Host: Human interaction agent + Validation string `json:"validation,omitempty" yaml:"validation,omitempty"` // Validation: Task output validation agent +} + +// GetPhaseAgent returns the globally configured agent ID for a robot pipeline phase. +// Returns empty string if no global default is set for the phase. +func (u *Uses) GetPhaseAgent(phase string) string { + if u == nil { + return "" + } + switch phase { + case "inspiration": + return u.Inspiration + case "goals": + return u.Goals + case "tasks": + return u.Tasks + case "delivery": + return u.Delivery + case "learning": + return u.Learning + case "host": + return u.Host + case "validation": + return u.Validation + default: + return "" + } } // System configures connectors for system agents diff --git a/openapi/agent/robot/completions.go b/openapi/agent/robot/completions.go index acca675c..fc1ad101 100644 --- a/openapi/agent/robot/completions.go +++ b/openapi/agent/robot/completions.go @@ -28,11 +28,9 @@ func resolveHostAssistantID(ctx context.Context, memberID string) (string, *robo return "", nil, fmt.Errorf("failed to parse robot config: %w", err) } - var hostID string - if config != nil && config.Resources != nil { - hostID = config.Resources.GetPhaseAgent(robottypes.PhaseHost) - } else { - hostID = "__yao." + string(robottypes.PhaseHost) + hostID := robottypes.ResolvePhaseAgent(config, robottypes.PhaseHost) + if hostID == "" { + return "", nil, fmt.Errorf("no Host Agent configured for robot %s (set uses.host in agent.yml or resources.phases in robot config)", memberID) } return hostID, record, nil