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:
parent
09af247a7c
commit
c4696380f4
13 changed files with 211 additions and 48 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue