feat(robot): implement global phase agent resolution for improved agent configuration

- Introduced a new global phase agent resolver to streamline agent ID retrieval for various robot pipeline phases, enhancing flexibility in agent configuration.
- Updated existing phase agent retrieval logic to prioritize per-robot configurations, falling back to global settings when necessary.
- Enhanced error handling to provide clearer messages when no agent is configured for specific phases.
- Added tests to validate the new resolution logic and ensure proper functionality across different configurations.
This commit is contained in:
Max 2026-03-24 12:11:55 +08:00
parent 09af247a7c
commit c4696380f4
13 changed files with 211 additions and 48 deletions

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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")
}

View file

@ -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

View file

@ -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)"},
}
}

View file

@ -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

View file

@ -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"))
})
}

View file

@ -52,6 +52,42 @@ type Uses struct {
Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Keyword extraction: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // QueryDSL generation: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
Rerank string `json:"rerank,omitempty" yaml:"rerank,omitempty"` // Result reranking: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
// 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

View file

@ -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