Merge pull request #1506 from trheyi/main
feat(robot): implement global phase agent resolution for improved agent configuration
This commit is contained in:
commit
cb9756b655
30 changed files with 1095 additions and 116 deletions
|
|
@ -390,6 +390,7 @@ func LoadPath(path string) (*Assistant, error) {
|
||||||
return nil, fmt.Errorf("load sandbox.yao: %w", sbErr)
|
return nil, fmt.Errorf("load sandbox.yao: %w", sbErr)
|
||||||
}
|
}
|
||||||
data["__sandbox_v2"] = sbCfg
|
data["__sandbox_v2"] = sbCfg
|
||||||
|
data["sandbox"] = sbCfg
|
||||||
}
|
}
|
||||||
|
|
||||||
ast, err := loadMap(data)
|
ast, err := loadMap(data)
|
||||||
|
|
@ -1100,8 +1101,15 @@ func mergeSearchConfig(base, override *searchTypes.Config) *searchTypes.Config {
|
||||||
|
|
||||||
// extractSandboxVersion tries to read the "version" field from a sandbox config value.
|
// extractSandboxVersion tries to read the "version" field from a sandbox config value.
|
||||||
func extractSandboxVersion(v any) string {
|
func extractSandboxVersion(v any) string {
|
||||||
if m, ok := v.(map[string]any); ok {
|
switch sb := v.(type) {
|
||||||
if ver, ok := m["version"].(string); ok {
|
case *sandboxTypes.SandboxConfig:
|
||||||
|
if sb != nil {
|
||||||
|
return sb.Version
|
||||||
|
}
|
||||||
|
case sandboxTypes.SandboxConfig:
|
||||||
|
return sb.Version
|
||||||
|
case map[string]any:
|
||||||
|
if ver, ok := sb["version"].(string); ok {
|
||||||
return ver
|
return ver
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/yaoapp/yao/agent/assistant"
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||||
searchDefaults "github.com/yaoapp/yao/agent/search/defaults"
|
searchDefaults "github.com/yaoapp/yao/agent/search/defaults"
|
||||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||||
storeMongo "github.com/yaoapp/yao/agent/store/mongo"
|
storeMongo "github.com/yaoapp/yao/agent/store/mongo"
|
||||||
|
|
@ -72,6 +73,15 @@ func Load(cfg config.Config) error {
|
||||||
|
|
||||||
agentDSL = &setting
|
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
|
// Store Setting
|
||||||
err = initStore()
|
err = initStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -477,6 +487,13 @@ func resolveEnvStrings(setting *types.DSL) {
|
||||||
setting.Uses.Keyword = helper.EnvString(setting.Uses.Keyword)
|
setting.Uses.Keyword = helper.EnvString(setting.Uses.Keyword)
|
||||||
setting.Uses.QueryDSL = helper.EnvString(setting.Uses.QueryDSL)
|
setting.Uses.QueryDSL = helper.EnvString(setting.Uses.QueryDSL)
|
||||||
setting.Uses.Rerank = helper.EnvString(setting.Uses.Rerank)
|
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)
|
setting.Cache = helper.EnvString(setting.Cache)
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ func TestE2ENormalExecutionNoSuspend(t *testing.T) {
|
||||||
result := triggerSuspendRobot(t, ctx, memberID, "Write a one-sentence greeting")
|
result := triggerSuspendRobot(t, ctx, memberID, "Write a one-sentence greeting")
|
||||||
|
|
||||||
exec := waitForStatus(t, result.ExecutionID,
|
exec := waitForStatus(t, result.ExecutionID,
|
||||||
[]types.ExecStatus{types.ExecCompleted, types.ExecFailed}, 60*time.Second)
|
[]types.ExecStatus{types.ExecCompleted, types.ExecFailed}, 120*time.Second)
|
||||||
|
|
||||||
require.NotNil(t, exec, "Execution should exist and reach terminal state")
|
require.NotNil(t, exec, "Execution should exist and reach terminal state")
|
||||||
if exec.Status == types.ExecFailed {
|
if exec.Status == types.ExecFailed {
|
||||||
|
|
@ -137,7 +137,7 @@ func TestE2ESuspendResumeFlow(t *testing.T) {
|
||||||
|
|
||||||
// Step 2: Wait for the execution to reach waiting status
|
// Step 2: Wait for the execution to reach waiting status
|
||||||
exec := waitForStatus(t, execID,
|
exec := waitForStatus(t, execID,
|
||||||
[]types.ExecStatus{types.ExecWaiting, types.ExecCompleted, types.ExecFailed}, 60*time.Second)
|
[]types.ExecStatus{types.ExecWaiting, types.ExecCompleted, types.ExecFailed}, 120*time.Second)
|
||||||
|
|
||||||
require.NotNil(t, exec, "Execution should exist")
|
require.NotNil(t, exec, "Execution should exist")
|
||||||
require.Equal(t, types.ExecWaiting, exec.Status, "Execution should be in waiting status")
|
require.Equal(t, types.ExecWaiting, exec.Status, "Execution should be in waiting status")
|
||||||
|
|
@ -157,9 +157,10 @@ func TestE2ESuspendResumeFlow(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, interactResult)
|
require.NotNil(t, interactResult)
|
||||||
|
|
||||||
// Since robot-need-input always signals need_input, the resumed execution
|
// The Host Agent may return a structured action (→ "waiting"/"resumed") or
|
||||||
// will re-suspend. The Interact API returns "waiting" status in this case.
|
// a conversational reply (→ "waiting_for_more") depending on LLM behaviour.
|
||||||
assert.Equal(t, "waiting", interactResult.Status, "Should re-suspend since assistant always signals need_input")
|
assert.Contains(t, []string{"waiting", "resumed", "waiting_for_more"}, interactResult.Status,
|
||||||
|
"Expected waiting, resumed, or waiting_for_more; got %s", interactResult.Status)
|
||||||
t.Logf("Interact result: status=%s message=%s", interactResult.Status, interactResult.Message)
|
t.Logf("Interact result: status=%s message=%s", interactResult.Status, interactResult.Message)
|
||||||
|
|
||||||
// Step 4: Verify the execution is in waiting status again (re-suspended)
|
// Step 4: Verify the execution is in waiting status again (re-suspended)
|
||||||
|
|
@ -192,7 +193,7 @@ func TestE2EReplyShortcut(t *testing.T) {
|
||||||
result := triggerSuspendRobot(t, ctx, memberID, "Check inventory levels")
|
result := triggerSuspendRobot(t, ctx, memberID, "Check inventory levels")
|
||||||
|
|
||||||
exec := waitForStatus(t, result.ExecutionID,
|
exec := waitForStatus(t, result.ExecutionID,
|
||||||
[]types.ExecStatus{types.ExecWaiting}, 60*time.Second)
|
[]types.ExecStatus{types.ExecWaiting}, 120*time.Second)
|
||||||
require.NotNil(t, exec, "Execution should reach waiting status")
|
require.NotNil(t, exec, "Execution should reach waiting status")
|
||||||
require.Equal(t, types.ExecWaiting, exec.Status)
|
require.Equal(t, types.ExecWaiting, exec.Status)
|
||||||
|
|
||||||
|
|
@ -200,7 +201,7 @@ func TestE2EReplyShortcut(t *testing.T) {
|
||||||
replyResult, err := api.Reply(ctx, memberID, result.ExecutionID, exec.WaitingTaskID, "Use warehouse A data")
|
replyResult, err := api.Reply(ctx, memberID, result.ExecutionID, exec.WaitingTaskID, "Use warehouse A data")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, replyResult)
|
require.NotNil(t, replyResult)
|
||||||
assert.Contains(t, []string{"waiting", "resumed"}, replyResult.Status)
|
assert.Contains(t, []string{"waiting", "resumed", "waiting_for_more"}, replyResult.Status)
|
||||||
t.Logf("Reply result: status=%s", replyResult.Status)
|
t.Logf("Reply result: status=%s", replyResult.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,7 +229,7 @@ func TestE2EResumeContextPersistence(t *testing.T) {
|
||||||
result := triggerSuspendRobot(t, ctx, memberID, "Analyze user behavior")
|
result := triggerSuspendRobot(t, ctx, memberID, "Analyze user behavior")
|
||||||
|
|
||||||
exec := waitForStatus(t, result.ExecutionID,
|
exec := waitForStatus(t, result.ExecutionID,
|
||||||
[]types.ExecStatus{types.ExecWaiting, types.ExecCompleted, types.ExecFailed}, 60*time.Second)
|
[]types.ExecStatus{types.ExecWaiting, types.ExecCompleted, types.ExecFailed}, 120*time.Second)
|
||||||
|
|
||||||
require.NotNil(t, exec)
|
require.NotNil(t, exec)
|
||||||
if exec.Status != types.ExecWaiting {
|
if exec.Status != types.ExecWaiting {
|
||||||
|
|
@ -297,7 +298,7 @@ func TestE2EInteractWithNonWaitingExecution(t *testing.T) {
|
||||||
|
|
||||||
// Wait for completion
|
// Wait for completion
|
||||||
exec := waitForStatus(t, result.ExecutionID,
|
exec := waitForStatus(t, result.ExecutionID,
|
||||||
[]types.ExecStatus{types.ExecCompleted, types.ExecFailed}, 60*time.Second)
|
[]types.ExecStatus{types.ExecCompleted, types.ExecFailed}, 120*time.Second)
|
||||||
require.NotNil(t, exec, "Execution should reach terminal state")
|
require.NotNil(t, exec, "Execution should reach terminal state")
|
||||||
|
|
||||||
// Try to interact with the completed execution
|
// Try to interact with the completed execution
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,10 @@ func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Executi
|
||||||
locale := getEffectiveLocale(robot, exec.Input)
|
locale := getEffectiveLocale(robot, exec.Input)
|
||||||
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "generating_delivery"))
|
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "generating_delivery"))
|
||||||
|
|
||||||
agentID := "__yao.delivery"
|
// Get agent ID for delivery phase (per-robot config > global Uses > empty)
|
||||||
if robot.Config != nil && robot.Config.Resources != nil {
|
agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseDelivery)
|
||||||
agentID = robot.Config.Resources.GetPhaseAgent(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()
|
formatter := NewInputFormatter()
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,10 @@ func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution,
|
||||||
locale := getEffectiveLocale(robot, exec.Input)
|
locale := getEffectiveLocale(robot, exec.Input)
|
||||||
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "planning_goals"))
|
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "planning_goals"))
|
||||||
|
|
||||||
// Get agent ID for goals phase
|
// Get agent ID for goals phase (per-robot config > global Uses > empty)
|
||||||
agentID := "__yao.goals" // default
|
agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseGoals)
|
||||||
if robot.Config != nil && robot.Config.Resources != nil {
|
if agentID == "" {
|
||||||
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseGoals)
|
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
|
// 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")
|
return nil, fmt.Errorf("robot cannot be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
agentID := ""
|
// Get agent ID for host phase (per-robot config > global Uses > empty)
|
||||||
if robot.Config != nil && robot.Config.Resources != nil {
|
agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseHost)
|
||||||
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseHost)
|
|
||||||
}
|
|
||||||
if agentID == "" {
|
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)
|
inputJSON, err := json.Marshal(input)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ func TestCallHostAgent_NilRobot(t *testing.T) {
|
||||||
|
|
||||||
// H2: no Host Agent configured
|
// H2: no Host Agent configured
|
||||||
func TestCallHostAgent_NoHostAgent(t *testing.T) {
|
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()
|
e := standard.New()
|
||||||
ctx := robottypes.NewContext(context.Background(), nil)
|
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(), "")
|
clock = robottypes.NewClockContext(time.Now(), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get agent ID for inspiration phase
|
// Get agent ID for inspiration phase (per-robot config > global Uses > empty)
|
||||||
agentID := "__yao.inspiration" // default
|
agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseInspiration)
|
||||||
if robot.Config != nil && robot.Config.Resources != nil {
|
if agentID == "" {
|
||||||
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseInspiration)
|
return fmt.Errorf("no Inspiration Agent configured (set uses.inspiration in agent.yml or resources.phases in robot config)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build prompt using InputFormatter
|
// Build prompt using InputFormatter
|
||||||
|
|
|
||||||
|
|
@ -215,13 +215,13 @@ func TestRunInspirationWithDefaultAgent(t *testing.T) {
|
||||||
|
|
||||||
ctx := types.NewContext(context.Background(), testAuth())
|
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{
|
robot := &types.Robot{
|
||||||
MemberID: "test-robot-1",
|
MemberID: "test-robot-1",
|
||||||
TeamID: "test-team-1",
|
TeamID: "test-team-1",
|
||||||
Config: &types.Config{
|
Config: &types.Config{
|
||||||
Identity: &types.Identity{Role: "Test Robot"},
|
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)
|
exec := createTestExecution(robot, types.TriggerClock)
|
||||||
|
|
@ -229,9 +229,8 @@ func TestRunInspirationWithDefaultAgent(t *testing.T) {
|
||||||
e := standard.New()
|
e := standard.New()
|
||||||
err := e.RunInspiration(ctx, exec, nil)
|
err := e.RunInspiration(ctx, exec, nil)
|
||||||
|
|
||||||
// This will fail if __yao.inspiration doesn't exist
|
// With global Uses configured (agent.yml: uses.inspiration = "robot.inspiration"),
|
||||||
// In test environment, we expect it to fail with "agent not found"
|
// the call should succeed. Without global config, it would error.
|
||||||
// In production, it would use the default agent
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
assert.Contains(t, err.Error(), "call failed")
|
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")
|
return fmt.Errorf("goals not available for task planning")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get agent ID for tasks phase
|
// Get agent ID for tasks phase (per-robot config > global Uses > empty)
|
||||||
agentID := "__yao.tasks" // default
|
agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseTasks)
|
||||||
if robot.Config != nil && robot.Config.Resources != nil {
|
if agentID == "" {
|
||||||
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseTasks)
|
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
|
// 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
|
// validateSemantic performs semantic validation using the Validation Agent
|
||||||
func (v *Validator) validateSemantic(task *robottypes.Task, output interface{}) *robottypes.ValidationResult {
|
func (v *Validator) validateSemantic(task *robottypes.Task, output interface{}) *robottypes.ValidationResult {
|
||||||
// Get validation agent ID
|
// Get validation agent ID (per-robot config > global Uses > empty)
|
||||||
validationAgentID := "__yao.validation" // default
|
validationAgentID := robottypes.ResolvePhaseAgent(v.robot.Config, "validation")
|
||||||
if v.robot.Config != nil && v.robot.Config.Resources != nil {
|
if validationAgentID == "" {
|
||||||
if customID, ok := v.robot.Config.Resources.Phases["validation"]; ok && customID != "" {
|
return &robottypes.ValidationResult{
|
||||||
validationAgentID = customID
|
Passed: false,
|
||||||
|
Score: 0,
|
||||||
|
Issues: []string{"no Validation Agent configured (set uses.validation in agent.yml or resources.phases in robot config)"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,10 @@ func TestIntegrationSchedulingFlow(t *testing.T) {
|
||||||
// Setup: Create a robot with times mode clock config
|
// Setup: Create a robot with times mode clock config
|
||||||
setupIntegrationRobotTimes(t, "robot_integ_flow_clock", "team_integ_flow")
|
setupIntegrationRobotTimes(t, "robot_integ_flow_clock", "team_integ_flow")
|
||||||
|
|
||||||
// Create manager with slow tick interval to avoid auto-tick interference
|
|
||||||
config := &manager.Config{
|
config := &manager.Config{
|
||||||
TickInterval: 10 * time.Second,
|
TickInterval: 10 * time.Second,
|
||||||
PoolConfig: &pool.Config{WorkerSize: 5, QueueSize: 50},
|
PoolConfig: &pool.Config{WorkerSize: 5, QueueSize: 50},
|
||||||
|
Executor: executor.NewDryRun(),
|
||||||
}
|
}
|
||||||
m := manager.NewWithConfig(config)
|
m := manager.NewWithConfig(config)
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ func TestIntegrationSchedulingFlow(t *testing.T) {
|
||||||
setupIntegrationRobotTimes(t, "robot_integ_flow_db1", "team_integ_flow")
|
setupIntegrationRobotTimes(t, "robot_integ_flow_db1", "team_integ_flow")
|
||||||
setupIntegrationRobotInterval(t, "robot_integ_flow_db2", "team_integ_flow")
|
setupIntegrationRobotInterval(t, "robot_integ_flow_db2", "team_integ_flow")
|
||||||
|
|
||||||
m := manager.New()
|
m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()})
|
||||||
err := m.Start()
|
err := m.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer m.Stop()
|
defer m.Stop()
|
||||||
|
|
@ -116,7 +116,7 @@ func TestIntegrationSchedulingFlow(t *testing.T) {
|
||||||
// Setup: Create an inactive robot
|
// Setup: Create an inactive robot
|
||||||
setupIntegrationRobotInactive(t, "robot_integ_flow_inactive", "team_integ_flow")
|
setupIntegrationRobotInactive(t, "robot_integ_flow_inactive", "team_integ_flow")
|
||||||
|
|
||||||
m := manager.New()
|
m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()})
|
||||||
err := m.Start()
|
err := m.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer m.Stop()
|
defer m.Stop()
|
||||||
|
|
@ -130,7 +130,7 @@ func TestIntegrationSchedulingFlow(t *testing.T) {
|
||||||
// Setup: Create a robot with autonomous_mode=false
|
// Setup: Create a robot with autonomous_mode=false
|
||||||
setupIntegrationRobotNonAutonomous(t, "robot_integ_flow_nonauto", "team_integ_flow")
|
setupIntegrationRobotNonAutonomous(t, "robot_integ_flow_nonauto", "team_integ_flow")
|
||||||
|
|
||||||
m := manager.New()
|
m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()})
|
||||||
err := m.Start()
|
err := m.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer m.Stop()
|
defer m.Stop()
|
||||||
|
|
|
||||||
|
|
@ -274,13 +274,28 @@ func (m *Manager) Tick(parentCtx context.Context, now time.Time) error {
|
||||||
// continue
|
// continue
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Pre-generate execution ID and track for pause/resume/stop
|
// Pre-generate execution ID
|
||||||
// We need to track BEFORE submit so we can pass the cancellable context to the executor
|
|
||||||
execID := pool.GenerateExecID()
|
execID := pool.GenerateExecID()
|
||||||
|
|
||||||
|
// Pre-acquire execution slot to prevent daemon-mode race condition:
|
||||||
|
// Without this, CanRun() stays true between Tick and worker dequeue,
|
||||||
|
// causing duplicate submissions on every tick interval.
|
||||||
|
preExec := &types.Execution{
|
||||||
|
ID: execID,
|
||||||
|
MemberID: robot.MemberID,
|
||||||
|
TeamID: robot.TeamID,
|
||||||
|
TriggerType: types.TriggerClock,
|
||||||
|
Status: types.ExecPending,
|
||||||
|
StartTime: now,
|
||||||
|
}
|
||||||
|
if !robot.TryAcquireSlot(preExec) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track for pause/resume/stop — after slot is acquired
|
||||||
ctrlExec := m.execController.Track(execID, robot.MemberID, robot.TeamID)
|
ctrlExec := m.execController.Track(execID, robot.MemberID, robot.TeamID)
|
||||||
|
|
||||||
// Create context with robot's own identity and cancellable context
|
// Create context with robot's own identity and cancellable context
|
||||||
// Clock-triggered executions run as the robot itself
|
|
||||||
robotAuth := m.buildRobotAuth(robot)
|
robotAuth := m.buildRobotAuth(robot)
|
||||||
execCtx := types.NewContext(ctrlExec.Context(), robotAuth)
|
execCtx := types.NewContext(ctrlExec.Context(), robotAuth)
|
||||||
|
|
||||||
|
|
@ -290,10 +305,8 @@ func (m *Manager) Tick(parentCtx context.Context, now time.Time) error {
|
||||||
// Submit to pool with the cancellable context and execution control
|
// Submit to pool with the cancellable context and execution control
|
||||||
_, err := m.pool.SubmitWithID(execCtx, robot, types.TriggerClock, clockCtx, execID, ctrlExec)
|
_, err := m.pool.SubmitWithID(execCtx, robot, types.TriggerClock, clockCtx, execID, ctrlExec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If submission failed, untrack the execution
|
robot.RemoveExecution(execID)
|
||||||
m.execController.Untrack(execID)
|
m.execController.Untrack(execID)
|
||||||
// Log error but continue with other robots
|
|
||||||
// In production, this would be logged properly
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/yaoapp/gou/model"
|
"github.com/yaoapp/gou/model"
|
||||||
"github.com/yaoapp/xun/capsule"
|
"github.com/yaoapp/xun/capsule"
|
||||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/robot/executor"
|
||||||
"github.com/yaoapp/yao/agent/robot/manager"
|
"github.com/yaoapp/yao/agent/robot/manager"
|
||||||
"github.com/yaoapp/yao/agent/robot/pool"
|
"github.com/yaoapp/yao/agent/robot/pool"
|
||||||
"github.com/yaoapp/yao/agent/robot/types"
|
"github.com/yaoapp/yao/agent/robot/types"
|
||||||
|
|
@ -89,10 +90,10 @@ func TestManagerTick(t *testing.T) {
|
||||||
defer cleanupTestRobots(t)
|
defer cleanupTestRobots(t)
|
||||||
|
|
||||||
t.Run("tick with times mode - matching time", func(t *testing.T) {
|
t.Run("tick with times mode - matching time", func(t *testing.T) {
|
||||||
// Create manager with short tick interval for testing
|
|
||||||
config := &manager.Config{
|
config := &manager.Config{
|
||||||
TickInterval: 100 * time.Millisecond,
|
TickInterval: 100 * time.Millisecond,
|
||||||
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10},
|
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10},
|
||||||
|
Executor: executor.NewDryRun(),
|
||||||
}
|
}
|
||||||
m := manager.NewWithConfig(config)
|
m := manager.NewWithConfig(config)
|
||||||
|
|
||||||
|
|
@ -121,6 +122,7 @@ func TestManagerTick(t *testing.T) {
|
||||||
config := &manager.Config{
|
config := &manager.Config{
|
||||||
TickInterval: 100 * time.Millisecond,
|
TickInterval: 100 * time.Millisecond,
|
||||||
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10},
|
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10},
|
||||||
|
Executor: executor.NewDryRun(),
|
||||||
}
|
}
|
||||||
m := manager.NewWithConfig(config)
|
m := manager.NewWithConfig(config)
|
||||||
|
|
||||||
|
|
@ -153,6 +155,7 @@ func TestManagerTick(t *testing.T) {
|
||||||
config := &manager.Config{
|
config := &manager.Config{
|
||||||
TickInterval: 100 * time.Millisecond,
|
TickInterval: 100 * time.Millisecond,
|
||||||
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10},
|
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10},
|
||||||
|
Executor: executor.NewDryRun(),
|
||||||
}
|
}
|
||||||
m := manager.NewWithConfig(config)
|
m := manager.NewWithConfig(config)
|
||||||
|
|
||||||
|
|
@ -181,6 +184,7 @@ func TestManagerTick(t *testing.T) {
|
||||||
config := &manager.Config{
|
config := &manager.Config{
|
||||||
TickInterval: 100 * time.Millisecond,
|
TickInterval: 100 * time.Millisecond,
|
||||||
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10},
|
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 10},
|
||||||
|
Executor: executor.NewDryRun(),
|
||||||
}
|
}
|
||||||
m := manager.NewWithConfig(config)
|
m := manager.NewWithConfig(config)
|
||||||
|
|
||||||
|
|
@ -295,7 +299,7 @@ func TestManagerClockModes(t *testing.T) {
|
||||||
defer cleanupTestRobots(t)
|
defer cleanupTestRobots(t)
|
||||||
|
|
||||||
t.Run("times mode - day matching", func(t *testing.T) {
|
t.Run("times mode - day matching", func(t *testing.T) {
|
||||||
m := manager.New()
|
m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()})
|
||||||
err := m.Start()
|
err := m.Start()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer m.Stop()
|
defer m.Stop()
|
||||||
|
|
@ -315,7 +319,7 @@ func TestManagerClockModes(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("times mode - day not matching", func(t *testing.T) {
|
t.Run("times mode - day not matching", func(t *testing.T) {
|
||||||
m := manager.New()
|
m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()})
|
||||||
err := m.Start()
|
err := m.Start()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer m.Stop()
|
defer m.Stop()
|
||||||
|
|
@ -336,7 +340,7 @@ func TestManagerClockModes(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("daemon mode - always triggers when idle", func(t *testing.T) {
|
t.Run("daemon mode - always triggers when idle", func(t *testing.T) {
|
||||||
m := manager.New()
|
m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()})
|
||||||
err := m.Start()
|
err := m.Start()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer m.Stop()
|
defer m.Stop()
|
||||||
|
|
@ -374,7 +378,7 @@ func TestManagerTimezoneDedup(t *testing.T) {
|
||||||
defer cleanupTestRobots(t)
|
defer cleanupTestRobots(t)
|
||||||
|
|
||||||
t.Run("times mode - same minute same day should not trigger twice", func(t *testing.T) {
|
t.Run("times mode - same minute same day should not trigger twice", func(t *testing.T) {
|
||||||
m := manager.New()
|
m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()})
|
||||||
err := m.Start()
|
err := m.Start()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer m.Stop()
|
defer m.Stop()
|
||||||
|
|
@ -414,7 +418,7 @@ func TestManagerTimezoneDedup(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("times mode - different day should trigger again", func(t *testing.T) {
|
t.Run("times mode - different day should trigger again", func(t *testing.T) {
|
||||||
m := manager.New()
|
m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()})
|
||||||
err := m.Start()
|
err := m.Start()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer m.Stop()
|
defer m.Stop()
|
||||||
|
|
@ -444,7 +448,7 @@ func TestManagerTimezoneDedup(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("times mode - cross-timezone day boundary", func(t *testing.T) {
|
t.Run("times mode - cross-timezone day boundary", func(t *testing.T) {
|
||||||
m := manager.New()
|
m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()})
|
||||||
err := m.Start()
|
err := m.Start()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer m.Stop()
|
defer m.Stop()
|
||||||
|
|
@ -477,7 +481,7 @@ func TestManagerTimezoneDedup(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("times mode - UTC vs local timezone comparison", func(t *testing.T) {
|
t.Run("times mode - UTC vs local timezone comparison", func(t *testing.T) {
|
||||||
m := manager.New()
|
m := manager.NewWithConfig(&manager.Config{Executor: executor.NewDryRun()})
|
||||||
err := m.Start()
|
err := m.Start()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer m.Stop()
|
defer m.Stop()
|
||||||
|
|
|
||||||
|
|
@ -66,10 +66,10 @@ func (w *Worker) run() {
|
||||||
|
|
||||||
// execute processes a single queue item
|
// execute processes a single queue item
|
||||||
func (w *Worker) execute(item *QueueItem) {
|
func (w *Worker) execute(item *QueueItem) {
|
||||||
// Pre-check if robot can run (non-atomic, just for early rejection)
|
// Pre-check if robot can run (non-atomic, just for early rejection).
|
||||||
// The actual atomic check happens inside Executor.Execute() via TryAcquireSlot()
|
// Skip for jobs whose slot was pre-acquired by Tick — they already hold
|
||||||
if !item.Robot.CanRun() {
|
// a reserved slot and will pass TryAcquireSlot idempotently.
|
||||||
// Robot likely at quota, re-enqueue for later
|
if item.Robot.GetExecution(item.ExecID) == nil && !item.Robot.CanRun() {
|
||||||
w.requeue(item, "quota pre-check failed")
|
w.requeue(item, "quota pre-check failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -258,14 +258,47 @@ type Resources struct {
|
||||||
MCP []MCPConfig `json:"mcp,omitempty"`
|
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 {
|
func (r *Resources) GetPhaseAgent(phase Phase) string {
|
||||||
if r != nil && r.Phases != nil {
|
if r != nil && r.Phases != nil {
|
||||||
if id, ok := r.Phases[phase]; ok && id != "" {
|
if id, ok := r.Phases[phase]; ok && id != "" {
|
||||||
return 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
|
// MCPConfig - MCP server configuration
|
||||||
|
|
|
||||||
|
|
@ -216,18 +216,26 @@ func TestQuotaDefaults(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResourcesGetPhaseAgent(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
|
var resources *types.Resources
|
||||||
agent := resources.GetPhaseAgent(types.PhaseGoals)
|
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{
|
resources := &types.Resources{
|
||||||
Phases: map[types.Phase]string{},
|
Phases: map[types.Phase]string{},
|
||||||
}
|
}
|
||||||
agent := resources.GetPhaseAgent(types.PhaseGoals)
|
agent := resources.GetPhaseAgent(types.PhaseGoals)
|
||||||
assert.Equal(t, "__yao.goals", agent)
|
assert.Equal(t, "", agent)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("custom phase agent", func(t *testing.T) {
|
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)
|
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{}
|
resources := &types.Resources{}
|
||||||
assert.Equal(t, "__yao.inspiration", resources.GetPhaseAgent(types.PhaseInspiration))
|
assert.Equal(t, "global.inspiration", resources.GetPhaseAgent(types.PhaseInspiration))
|
||||||
assert.Equal(t, "__yao.goals", resources.GetPhaseAgent(types.PhaseGoals))
|
assert.Equal(t, "global.goals", resources.GetPhaseAgent(types.PhaseGoals))
|
||||||
assert.Equal(t, "__yao.tasks", resources.GetPhaseAgent(types.PhaseTasks))
|
assert.Equal(t, "global.tasks", resources.GetPhaseAgent(types.PhaseTasks))
|
||||||
assert.Equal(t, "__yao.run", resources.GetPhaseAgent(types.PhaseRun))
|
assert.Equal(t, "global.run", resources.GetPhaseAgent(types.PhaseRun))
|
||||||
assert.Equal(t, "__yao.delivery", resources.GetPhaseAgent(types.PhaseDelivery))
|
assert.Equal(t, "global.delivery", resources.GetPhaseAgent(types.PhaseDelivery))
|
||||||
assert.Equal(t, "__yao.learning", resources.GetPhaseAgent(types.PhaseLearning))
|
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,25 +52,33 @@ func (r *Robot) CanRun() bool {
|
||||||
return len(r.executions) < r.Config.Quota.GetMax()
|
return len(r.executions) < r.Config.Quota.GetMax()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TryAcquireSlot atomically checks if robot can run and reserves a slot
|
// TryAcquireSlot atomically checks if robot can run and reserves a slot.
|
||||||
// Returns true if slot was acquired, false if quota is full
|
// Returns true if slot was acquired, false if quota is full.
|
||||||
// This prevents race conditions between CanRun() check and AddExecution()
|
// Idempotent: if exec.ID already exists in tracking, the entry is updated
|
||||||
|
// and true is returned without consuming an additional slot. This supports
|
||||||
|
// the Tick pre-acquisition pattern where the slot is reserved early and
|
||||||
|
// later confirmed by the executor with a richer Execution object.
|
||||||
func (r *Robot) TryAcquireSlot(exec *Execution) bool {
|
func (r *Robot) TryAcquireSlot(exec *Execution) bool {
|
||||||
r.execMu.Lock()
|
r.execMu.Lock()
|
||||||
defer r.execMu.Unlock()
|
defer r.execMu.Unlock()
|
||||||
|
|
||||||
// Get max quota
|
// Idempotent: same ID already tracked — update in place
|
||||||
|
if r.executions != nil {
|
||||||
|
if _, exists := r.executions[exec.ID]; exists {
|
||||||
|
r.executions[exec.ID] = exec
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
maxQuota := 2 // default
|
maxQuota := 2 // default
|
||||||
if r.Config != nil {
|
if r.Config != nil {
|
||||||
maxQuota = r.Config.Quota.GetMax()
|
maxQuota = r.Config.Quota.GetMax()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we can add
|
|
||||||
if len(r.executions) >= maxQuota {
|
if len(r.executions) >= maxQuota {
|
||||||
return false // quota full
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reserve slot by adding execution
|
|
||||||
if r.executions == nil {
|
if r.executions == nil {
|
||||||
r.executions = make(map[string]*Execution)
|
r.executions = make(map[string]*Execution)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,12 @@ func (p *streamParser) onContentBlockDelta(event map[string]any) (stopped bool)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// If there is no active text message and this delta is only
|
||||||
|
// whitespace, buffer it instead of opening a brand-new message
|
||||||
|
// group just for spaces/indentation between tool calls.
|
||||||
|
if !p.textActive && strings.TrimSpace(text) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if p.ensureTextMessage() {
|
if p.ensureTextMessage() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -448,6 +454,12 @@ func (p *streamParser) handleUser(msg map[string]any) (stopped bool) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close any open text message before opening an execute message,
|
||||||
|
// otherwise textActive stays true while currentGroupID gets
|
||||||
|
// overwritten by the execute message lifecycle, causing subsequent
|
||||||
|
// text chunks to be emitted without a message_id.
|
||||||
|
p.closeTextMessage()
|
||||||
|
|
||||||
toolUseID, _ := ci["tool_use_id"].(string)
|
toolUseID, _ := ci["tool_use_id"].(string)
|
||||||
content := ci["content"]
|
content := ci["content"]
|
||||||
isError, _ := ci["is_error"].(bool)
|
isError, _ := ci["is_error"].(bool)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
|
|
@ -52,7 +51,7 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e
|
||||||
src := "local:///" + req.SkillsDir
|
src := "local:///" + req.SkillsDir
|
||||||
dst := prefix + "/skills"
|
dst := prefix + "/skills"
|
||||||
if _, err := ws.Copy(src, dst); err != nil {
|
if _, err := ws.Copy(src, dst); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[claude] warn: copy skills %s -> %s: %v\n", src, dst, err)
|
r.logger.Warn("copy skills %s -> %s: %v", src, dst, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +114,10 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han
|
||||||
|
|
||||||
completed, err := sess.runStream(handler)
|
completed, err := sess.runStream(handler)
|
||||||
r.lastCompleted = completed
|
r.lastCompleted = completed
|
||||||
|
r.logger.Debug("Stream: runStream returned completed=%v err=%v", completed, err)
|
||||||
|
if completed {
|
||||||
|
sess.shutdown()
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ func (s *session) runStream(handler message.StreamFunc) (completed bool, err err
|
||||||
parser := newStreamParser(handler)
|
parser := newStreamParser(handler)
|
||||||
parseErr := parser.parse(s.ctx, s.exec.Stdout)
|
parseErr := parser.parse(s.ctx, s.exec.Stdout)
|
||||||
|
|
||||||
|
s.logger.Debug("runStream: parse returned completed=%v parseErr=%v", parser.completed, parseErr)
|
||||||
|
|
||||||
if parser.completed {
|
if parser.completed {
|
||||||
s.logger.Info("claude stream completed normally")
|
s.logger.Info("claude stream completed normally")
|
||||||
return true, nil
|
return true, nil
|
||||||
|
|
@ -127,6 +129,26 @@ func (s *session) watchCancel() func() {
|
||||||
return func() { close(done) }
|
return func() { close(done) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shutdown terminates the claude process after a normal stream completion.
|
||||||
|
//
|
||||||
|
// Claude CLI's stream-json mode has a known bug where the process hangs
|
||||||
|
// indefinitely after emitting the "result" event (anthropics/claude-code#25629).
|
||||||
|
// There is no graceful exit mechanism, so we must kill the process externally.
|
||||||
|
//
|
||||||
|
// We send SIGKILL (-9) to processes named exactly "claude". SIGKILL cannot be
|
||||||
|
// caught, so Claude CLI has no opportunity to run its SIGTERM handler which
|
||||||
|
// would actively terminate child processes (web servers, etc.). Those children
|
||||||
|
// survive because they run in separate process groups/sessions.
|
||||||
|
func (s *session) shutdown() {
|
||||||
|
s.logger.Info("shutting down completed claude exec session")
|
||||||
|
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := s.computer.Exec(killCtx, []string{"sh", "-c", "pkill -9 -x claude || true"})
|
||||||
|
s.logger.Debug("shutdown: pkill -9 -x claude exitCode=%d err=%v", result.ExitCode, err)
|
||||||
|
s.exec.Cancel()
|
||||||
|
}
|
||||||
|
|
||||||
// waitForExit waits for the Claude process to exit with timeout protection.
|
// waitForExit waits for the Claude process to exit with timeout protection.
|
||||||
// This fixes the old code's issue where Wait() could block forever.
|
// This fixes the old code's issue where Wait() could block forever.
|
||||||
func (s *session) waitForExit(parseErr error) error {
|
func (s *session) waitForExit(parseErr error) error {
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,19 @@ import (
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types"
|
||||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func extractSandboxVersion(v any) string {
|
||||||
|
if m, ok := v.(map[string]any); ok {
|
||||||
|
if ver, ok := m["version"].(string); ok {
|
||||||
|
return ver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// ToKnowledgeBase converts various types to KnowledgeBase
|
// ToKnowledgeBase converts various types to KnowledgeBase
|
||||||
func ToKnowledgeBase(v interface{}) (*KnowledgeBase, error) {
|
func ToKnowledgeBase(v interface{}) (*KnowledgeBase, error) {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
|
|
@ -426,9 +436,20 @@ func ToAssistantModel(v interface{}) (*AssistantModel, error) {
|
||||||
|
|
||||||
// Sandbox
|
// Sandbox
|
||||||
if sandbox, ok := data["sandbox"]; ok && sandbox != nil {
|
if sandbox, ok := data["sandbox"]; ok && sandbox != nil {
|
||||||
sb, err := ToSandbox(sandbox)
|
if extractSandboxVersion(sandbox) == sandboxTypes.SandboxVersionV2 {
|
||||||
if err == nil {
|
sb, err := ToSandboxV2(sandbox)
|
||||||
model.Sandbox = sb
|
if err == nil {
|
||||||
|
model.SandboxV2 = sb
|
||||||
|
model.IsSandbox = true
|
||||||
|
if sb.Filter != nil {
|
||||||
|
model.ComputerFilter = sb.Filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb, err := ToSandbox(sandbox)
|
||||||
|
if err == nil {
|
||||||
|
model.Sandbox = sb
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ import (
|
||||||
"github.com/yaoapp/yao/agent/store/types"
|
"github.com/yaoapp/yao/agent/store/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func sandboxForDB(a *types.AssistantModel) interface{} {
|
||||||
|
if a.SandboxV2 != nil {
|
||||||
|
return a.SandboxV2
|
||||||
|
}
|
||||||
|
return a.Sandbox
|
||||||
|
}
|
||||||
|
|
||||||
// SaveAssistant saves assistant information
|
// SaveAssistant saves assistant information
|
||||||
func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error) {
|
func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error) {
|
||||||
if assistant == nil {
|
if assistant == nil {
|
||||||
|
|
@ -165,7 +172,7 @@ func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error)
|
||||||
"db": assistant.DB,
|
"db": assistant.DB,
|
||||||
"mcp": assistant.MCP,
|
"mcp": assistant.MCP,
|
||||||
"workflow": assistant.Workflow,
|
"workflow": assistant.Workflow,
|
||||||
"sandbox": assistant.Sandbox,
|
"sandbox": sandboxForDB(assistant),
|
||||||
"placeholder": assistant.Placeholder,
|
"placeholder": assistant.Placeholder,
|
||||||
"locales": assistant.Locales,
|
"locales": assistant.Locales,
|
||||||
"uses": assistant.Uses,
|
"uses": assistant.Uses,
|
||||||
|
|
@ -763,12 +770,12 @@ func (store *Xun) DeleteAssistants(filter types.AssistantFilter) (int64, error)
|
||||||
func (store *Xun) GetAssistantTags(filter types.AssistantFilter, locale ...string) ([]types.Tag, error) {
|
func (store *Xun) GetAssistantTags(filter types.AssistantFilter, locale ...string) ([]types.Tag, error) {
|
||||||
qb := store.query.New().Table(store.getAssistantTable())
|
qb := store.query.New().Table(store.getAssistantTable())
|
||||||
|
|
||||||
// Apply type filter (default to "assistant")
|
// Apply type filter
|
||||||
typeFilter := "assistant"
|
if len(filter.Types) > 0 {
|
||||||
if filter.Type != "" {
|
qb.WhereIn("type", filter.Types)
|
||||||
typeFilter = filter.Type
|
} else if filter.Type != "" {
|
||||||
|
qb.Where("type", filter.Type)
|
||||||
}
|
}
|
||||||
qb.Where("type", typeFilter)
|
|
||||||
|
|
||||||
// Apply custom query filter function (for permission filtering)
|
// Apply custom query filter function (for permission filtering)
|
||||||
if filter.QueryFilter != nil {
|
if filter.QueryFilter != nil {
|
||||||
|
|
@ -895,10 +902,7 @@ func (store *Xun) translate(model *types.AssistantModel, assistantID string, loc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate tags
|
// Tags are NOT translated — they serve as filter keys and must remain
|
||||||
if translated := i18n.Translate(assistantID, locale, model.Tags); translated != nil {
|
// in their original (English) form so that filter.tags round-trips
|
||||||
if tags, ok := translated.([]string); ok {
|
// correctly through the LIKE query on the DB column.
|
||||||
model.Tags = tags
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/yaoapp/xun/dbal/query"
|
"github.com/yaoapp/xun/dbal/query"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
|
@ -2080,6 +2081,80 @@ func TestGetAssistantTags(t *testing.T) {
|
||||||
}
|
}
|
||||||
t.Logf("Found %d tags with complex permission filter", len(tagsComplex))
|
t.Logf("Found %d tags with complex permission filter", len(tagsComplex))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("GetTagsWithTypesFilter", func(t *testing.T) {
|
||||||
|
uniqueTag := fmt.Sprintf("types-tag-%d", time.Now().UnixNano())
|
||||||
|
robotTag := fmt.Sprintf("robot-tag-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
assistants := []types.AssistantModel{
|
||||||
|
{
|
||||||
|
Name: "Types Tag Assistant",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Tags: []string{uniqueTag, "shared"},
|
||||||
|
Share: "private",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Types Tag Robot",
|
||||||
|
Type: "robot",
|
||||||
|
Connector: "openai",
|
||||||
|
Tags: []string{robotTag, "shared"},
|
||||||
|
Share: "private",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, asst := range assistants {
|
||||||
|
_, err := store.SaveAssistant(&asst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create assistant: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, err := store.GetAssistantTags(types.AssistantFilter{
|
||||||
|
Types: []string{"assistant", "robot"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get tags with types filter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagValues := make(map[string]bool)
|
||||||
|
for _, tag := range tags {
|
||||||
|
tagValues[tag.Value] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, tagValues[uniqueTag], "Should contain assistant-type tag %s", uniqueTag)
|
||||||
|
assert.True(t, tagValues[robotTag], "Should contain robot-type tag %s", robotTag)
|
||||||
|
assert.True(t, tagValues["shared"], "Should contain shared tag")
|
||||||
|
t.Logf("Found %d tags for types=[assistant,robot]", len(tags))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetTagsWithSingleTypeVsTypes", func(t *testing.T) {
|
||||||
|
tagsSingleType, err := store.GetAssistantTags(types.AssistantFilter{
|
||||||
|
Type: "assistant",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get tags with single type: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsSliceType, err := store.GetAssistantTags(types.AssistantFilter{
|
||||||
|
Types: []string{"assistant"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get tags with types slice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, len(tagsSingleType), len(tagsSliceType),
|
||||||
|
"Type='assistant' and Types=['assistant'] should return same number of tags")
|
||||||
|
|
||||||
|
singleSet := make(map[string]bool)
|
||||||
|
for _, tag := range tagsSingleType {
|
||||||
|
singleSet[tag.Value] = true
|
||||||
|
}
|
||||||
|
for _, tag := range tagsSliceType {
|
||||||
|
assert.True(t, singleSet[tag.Value],
|
||||||
|
"Tag %q from Types query should also appear in Type query", tag.Value)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAssistantPermissionFields tests permission management fields
|
// TestAssistantPermissionFields tests permission management fields
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,42 @@ type Uses struct {
|
||||||
Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Keyword extraction: "builtin", "<assistant-id>", "mcp:<server>.<tool>"
|
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>"
|
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>"
|
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
|
// System configures connectors for system agents
|
||||||
|
|
|
||||||
475
openapi/agent/ISSUES.md
Normal file
475
openapi/agent/ISSUES.md
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
# Agent Assistants API — 问题分析
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档梳理 `GET /agent/assistants`(List API)和 `GET /agent/assistants/tags`(Tags API)
|
||||||
|
在前后端交互中存在的问题,涉及三个核心议题:
|
||||||
|
|
||||||
|
1. **分页参数冲突** — 前端请求的 pagesize 超出后端上限,实际返回数据量与预期不符
|
||||||
|
2. **Tags 列表与查询条件不一致** — 标签始终展示全量,搜索/筛选后不会动态更新
|
||||||
|
3. **Sandbox V2 识别断裂** — List API 返回的 `sandbox` 布尔值只反映 V1,V2 被遗漏
|
||||||
|
4. **Sandbox 锁定判断未适配 V2** — Card/详情页仍用 docker 判断,未考虑 `kind=host` 场景
|
||||||
|
5. **AgentPicker 组件未使用服务端能力** — 搜索、标签过滤、分页均在前端完成,数据不完整
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、前端调用场景对比
|
||||||
|
|
||||||
|
`AgentPicker` 组件被 3 个场景使用,各自的 filter 不同:
|
||||||
|
|
||||||
|
| 场景 | 文件 | mode | filter | 预期查询范围 |
|
||||||
|
|------|------|------|--------|-------------|
|
||||||
|
| 聊天框切换助手 | `chatbox/components/InputArea/AgentTag.tsx` | single | 无(不传 filter) | 与助手页面一致:`type=assistant` |
|
||||||
|
| MC 身份设定 — 可协作智能体 | `pages/mission-control/.../IdentityPanel.tsx` | multiple | `{ types: ['assistant', 'robot'], automated: true }` | assistant + robot 中 automated 的 |
|
||||||
|
| MC 添加智能体 — 可协作智能体 | `pages/mission-control/.../AddAgentModal/index.tsx` | multiple | `{ types: ['assistant', 'robot'], automated: true }` | 同上 |
|
||||||
|
|
||||||
|
聊天框场景不传 filter,后端默认 `type=assistant`,结果范围与助手页面一致(这是正确的)。
|
||||||
|
|
||||||
|
另外,**助手页面**(`pages/assistants/index.tsx`)不使用 AgentPicker,它有独立的列表实现:
|
||||||
|
|
||||||
|
| 维度 | 助手页面 | AgentPicker(聊天框) | AgentPicker(MC) |
|
||||||
|
|------|---------|---------------------|-------------------|
|
||||||
|
| pagesize | 12(正确分页) | 200(超出后端上限) | 200(超出后端上限) |
|
||||||
|
| 搜索 | `keywords`(服务端) | 前端内存过滤 | 前端内存过滤 |
|
||||||
|
| 标签过滤 | `tags`(服务端) | 前端内存过滤 | 前端内存过滤 |
|
||||||
|
| 标签列表 | `tags.List()` API | 从已加载数据聚合 | 从已加载数据聚合 |
|
||||||
|
| type | `type: 'assistant'` | 不传(默认 assistant) | `types: ['assistant', 'robot']` |
|
||||||
|
| 其他 filter | 无 | 无 | `automated: true` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、分页参数冲突(核心 Bug)
|
||||||
|
|
||||||
|
### 后端限制
|
||||||
|
|
||||||
|
```go
|
||||||
|
// openapi/agent/assistant.go:44-47
|
||||||
|
pagesize := 20
|
||||||
|
if pagesizeStr := c.Query("pagesize"); pagesizeStr != "" {
|
||||||
|
if ps, err := strconv.Atoi(pagesizeStr); err == nil && ps > 0 && ps <= 100 {
|
||||||
|
pagesize = ps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
后端 handler 对 `pagesize` 有 **硬上限 100**:当请求值 > 100 时,条件 `ps <= 100` 不满足,
|
||||||
|
`pagesize` **静默回落到默认值 20**,不报错。
|
||||||
|
|
||||||
|
`ValidatePagination` 也同样限制 `pagesize > 100` 报错,但 handler 的预处理已经把它截断为 20 了,
|
||||||
|
所以验证永远不会触发。
|
||||||
|
|
||||||
|
`BuildAssistantFilter` 再兜底:`PageSize > 100 → 100`。三层保护逻辑叠加,最终效果是
|
||||||
|
**超过 100 的请求静默变成 20**。
|
||||||
|
|
||||||
|
### 前端请求
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AgentPicker/index.tsx:66-71
|
||||||
|
api.assistants.List({
|
||||||
|
select: ['assistant_id', 'name', 'avatar', 'description', 'tags', 'connector', 'sandbox', 'built_in'],
|
||||||
|
locale: is_cn ? 'zh-cn' : 'en-us',
|
||||||
|
pagesize: 200, // ← 超出后端上限
|
||||||
|
...filter
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 实际效果
|
||||||
|
|
||||||
|
| 前端期望 | 后端实际行为 |
|
||||||
|
|---------|------------|
|
||||||
|
| 一次拉取 200 条 | 静默回落为 pagesize=20,只返回 20 条 |
|
||||||
|
| 聊天框场景(无 filter)不传 type | type 默认 `assistant`,不影响 |
|
||||||
|
| MC 场景传 `types: ['assistant', 'robot']` | 不传 type,后端不设默认 type,只用 types IN 查询,结果正确但仅 20 条 |
|
||||||
|
|
||||||
|
**AgentPicker 显示的最多只有 20 个助手**,而不是用户期望的全部。
|
||||||
|
左侧分类标签和计数也只基于这 20 条数据聚合,严重不准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Tags API 返回范围与查询条件不一致
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
|
||||||
|
助手页面(`pages/assistants/index.tsx`)的标签 Tab 栏显示的是 **所有** 标签,
|
||||||
|
而不是当前查询条件下的标签。当用户在搜索框中输入关键词后,标签 Tab 没有变化,
|
||||||
|
仍然展示全量标签,其中很多标签对应的搜索结果可能为零。
|
||||||
|
|
||||||
|
### 前端调用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pages/assistants/index.tsx:66-69 — Tags 加载(只在组件挂载时调用一次)
|
||||||
|
const response = await agent.tags.List({
|
||||||
|
locale: is_cn ? 'zh-cn' : 'en-us',
|
||||||
|
type: 'assistant'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Tags 加载在 `useEffect(() => { ... }, [is_cn])` 中,只依赖 `is_cn`,
|
||||||
|
**不会在搜索/筛选条件变化时重新加载**。
|
||||||
|
|
||||||
|
### 后端 Tags API 支持的参数
|
||||||
|
|
||||||
|
Tags handler(`ListAssistantTags`)支持以下过滤参数:
|
||||||
|
|
||||||
|
| 参数 | 支持 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `type` | 是 | 单个类型,默认 `assistant` |
|
||||||
|
| `types` | **否** | 不支持多类型 IN 查询 |
|
||||||
|
| `connector` | 是 | |
|
||||||
|
| `keywords` | 是 | 搜索 name/description |
|
||||||
|
| `built_in` | 是 | |
|
||||||
|
| `mentionable` | 是 | |
|
||||||
|
| `automated` | 是 | |
|
||||||
|
| `sandbox` | **否** | Tags API 不支持 |
|
||||||
|
| `tags` | **否** | Tags API 不接受(合理) |
|
||||||
|
|
||||||
|
### 应有的行为
|
||||||
|
|
||||||
|
当用户输入搜索关键词或切换其他筛选条件时,标签列表应该只显示
|
||||||
|
**在当前查询条件下存在助手的标签**。例如:
|
||||||
|
|
||||||
|
- 搜索 "Keeper" → 标签只显示 Data、Query、Ingestion 等 Keeper 相关助手的标签
|
||||||
|
- 没有匹配助手的标签应该消失(或显示为 0)
|
||||||
|
|
||||||
|
### 修复方向
|
||||||
|
|
||||||
|
1. **前端**:当搜索/筛选条件变化时,重新调用 `tags.List()` 并透传 `keywords` 等参数
|
||||||
|
2. **后端**(可选):Tags API 增加 `types` 参数支持,与 List API 对齐
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Sandbox V2 识别断裂
|
||||||
|
|
||||||
|
### 数据流断裂点
|
||||||
|
|
||||||
|
```
|
||||||
|
加载时(load.go) DB 读回(xun/assistant.go) List API(filter.go)
|
||||||
|
┌─────────────┐ ┌───────────────────┐ ┌────────────────────┐
|
||||||
|
│ sandbox.yao │ │ data["sandbox"] │ │ hasSandbox := │
|
||||||
|
│ version:2.0 │ │ ↓ │ │ a.Sandbox != nil │
|
||||||
|
│ ↓ │ │ ToSandbox() │ │ ↓ │
|
||||||
|
│ SandboxV2 ✓ │ │ → model.Sandbox │ │ sandbox: bool │
|
||||||
|
│ IsSandbox ✓ │ │ (V1 struct) │ │ (只看 V1) │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ 不走 DB │ │ 不调 ToSandboxV2 │ │ 不看 IsSandbox │
|
||||||
|
└─────────────┘ └───────────────────┘ └────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### AssistantModel 中的字段定义
|
||||||
|
|
||||||
|
```go
|
||||||
|
// agent/store/types/types.go:458-462
|
||||||
|
Sandbox *Sandbox `json:"sandbox,omitempty"` // V1 — 持久化到 DB
|
||||||
|
SandboxV2 *sandboxTypes.SandboxConfig `json:"-"` // V2 — 运行时,json:"-"
|
||||||
|
IsSandbox bool `json:"-"` // 运行时标记
|
||||||
|
ComputerFilter *sandboxTypes.ComputerFilter `json:"-"` // 运行时
|
||||||
|
```
|
||||||
|
|
||||||
|
`SandboxV2`、`IsSandbox`、`ComputerFilter` 都标了 `json:"-"`,仅在运行时内存中存在。
|
||||||
|
|
||||||
|
### List API 的处理
|
||||||
|
|
||||||
|
```go
|
||||||
|
// openapi/agent/filter.go:194-195
|
||||||
|
hasSandbox := a.Sandbox != nil // ← 只看 V1 的 Sandbox 指针
|
||||||
|
```
|
||||||
|
|
||||||
|
### GetInfo API 的处理(对比)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// agent/assistant/assistant.go:488
|
||||||
|
Sandbox: ast.IsSandbox // ← 看的是运行时 IsSandbox(V2 会为 true)
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB 读回路径
|
||||||
|
|
||||||
|
```go
|
||||||
|
// agent/store/xun/assistant.go:636-641(ToAssistantModel 中)
|
||||||
|
if sandbox, has := data["sandbox"]; has && sandbox != nil {
|
||||||
|
sb, err := types.ToSandbox(sandbox) // ← 只用 ToSandbox(V1)
|
||||||
|
if err == nil {
|
||||||
|
model.Sandbox = sb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没有 ToSandboxV2 调用,没有检查 version 字段
|
||||||
|
```
|
||||||
|
|
||||||
|
### V2 Sandbox 的两种配置方式
|
||||||
|
|
||||||
|
| 方式 | DB 中 sandbox 列 | 加载时 SandboxV2 | List API sandbox 布尔 |
|
||||||
|
|------|------------------|-----------------|---------------------|
|
||||||
|
| **独立 `sandbox.yao` 文件** | 可能为 NULL(sandbox 配置不在 package 里) | ✓(从文件加载) | **false**(DB 列为空 → Sandbox==nil) |
|
||||||
|
| **package.yao 内嵌 `sandbox` 块(version:2.0)** | 有 JSON(含 version:2.0) | ✓(从 DB JSON 解析) | **true**(ToSandbox 用 jsoniter 反序列化,忽略未知字段,返回空但非 nil 的 `*Sandbox`) |
|
||||||
|
|
||||||
|
对于 **独立 `sandbox.yao` 文件** 的 V2 助手:
|
||||||
|
- DB `sandbox` 列为 NULL 或 JSON null
|
||||||
|
- `ToSandbox` 返回 nil
|
||||||
|
- `hasSandbox = false`
|
||||||
|
- **List API 返回 `sandbox: false`,但 GetInfo API 返回 `sandbox: true`**
|
||||||
|
- 前端助手页卡片上不会显示电脑图标,AgentPicker 也无法识别
|
||||||
|
|
||||||
|
对于 **package.yao 内嵌 `sandbox` 块(version:2.0)** 的 V2 助手:
|
||||||
|
- DB `sandbox` 列有 JSON(含 version、computer、runner 等 V2 字段)
|
||||||
|
- `ToSandbox` 用 `jsoniter.Unmarshal` 到 V1 `Sandbox` 结构体,**忽略未知字段**,
|
||||||
|
返回一个空但非 nil 的 `*Sandbox{}`(command=""、image="" 等零值)
|
||||||
|
- `hasSandbox = true`(指针非 nil)
|
||||||
|
- **List API 返回 `sandbox: true`,凑巧正确,但依据错误**(实际是空 V1 对象,不是真的 V1 配置)
|
||||||
|
|
||||||
|
注意:`FilterBuiltInAssistant` 会对内置助手清除 `assistant.Sandbox = nil`,
|
||||||
|
但 `hasSandbox` 在清除前捕获(filter.go:195-196),所以不影响布尔值。
|
||||||
|
修复后若使用 `SandboxV2`/`IsSandbox`,因其标记 `json:"-"` 不会被 `FilterBuiltInAssistant` 清除,
|
||||||
|
也不会被 `json.Marshal` 输出,需在 `AssistantToResponse` 中手动追加到 result map。
|
||||||
|
|
||||||
|
### GetAssistantTags 的 Sandbox 情况
|
||||||
|
|
||||||
|
Tags API (`GET /agent/assistants/tags`) 不接受 `sandbox` 参数,也不返回 sandbox 相关信息。
|
||||||
|
这本身没问题,但 AgentPicker 没有使用 Tags API。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、Sandbox 锁定判断未适配 V2
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
|
||||||
|
助手 Card 和详情页的「聊天」按钮禁用逻辑仍使用 V1 时代的判断方式,
|
||||||
|
V2 引入了 `computer_filter.kind` 区分 `host`(宿主机)和 `box`(容器),
|
||||||
|
但列表页没有使用这个信息。
|
||||||
|
|
||||||
|
### V1 的判断(当前 Card 和详情页)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pages/assistants/components/Card.tsx:30-31
|
||||||
|
const dockerAvailable = (global.app_info as any)?.tools?.docker?.available === true
|
||||||
|
const chatDisabled = data.sandbox === true && !dockerAvailable
|
||||||
|
```
|
||||||
|
|
||||||
|
逻辑:sandbox 助手 + 没有 docker → 禁用聊天。**对 V1 是正确的**(V1 全部走容器)。
|
||||||
|
|
||||||
|
### V2 的变化
|
||||||
|
|
||||||
|
V2 sandbox 有 `ComputerFilter`,其中 `kind` 决定执行环境:
|
||||||
|
|
||||||
|
| kind | 含义 | 需要 docker |
|
||||||
|
|------|------|------------|
|
||||||
|
| `"host"` | 在宿主机执行 | 不需要 |
|
||||||
|
| `"box"` | 在容器中执行 | 需要 |
|
||||||
|
| `["host", "box"]` | 两种都支持 | 有一种匹配即可 |
|
||||||
|
|
||||||
|
InputArea 里**已经正确实现**了基于 `computer_filter` 的工作区兼容性检查:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// chatbox/components/InputArea/index.tsx:198-201
|
||||||
|
const kinds = Array.isArray(filter.kind) ? filter.kind : [filter.kind]
|
||||||
|
return !kinds.some((k) =>
|
||||||
|
k === 'host' ? caps.host_exec : k === 'box' ? caps.docker || caps.k8s : false
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
但 Card 和详情页**没有使用 `computer_filter`**,因为:
|
||||||
|
1. List API 不返回 `computer_filter`(它是运行时字段,`json:"-"`)
|
||||||
|
2. Card 只拿到了 `sandbox: boolean`,没有 kind 信息
|
||||||
|
3. 所以 Card 只能用旧的 `docker.available` 做兜底判断
|
||||||
|
|
||||||
|
### 实际影响
|
||||||
|
|
||||||
|
| 助手类型 | Card 上的判断 | 实际能否聊天 |
|
||||||
|
|---------|--------------|------------|
|
||||||
|
| V1 sandbox(容器) | `sandbox && !docker` → 正确禁用 | 确实不行 |
|
||||||
|
| V2 `kind=box`(容器) | `sandbox && !docker` → 正确禁用 | 确实不行 |
|
||||||
|
| V2 `kind=host`(宿主机) | `sandbox && !docker` → **错误禁用** | 其实可以(不需要 docker) |
|
||||||
|
| V2 `kind=["host","box"]` | `sandbox && !docker` → **错误禁用** | host 方式可以 |
|
||||||
|
|
||||||
|
### 修复方向
|
||||||
|
|
||||||
|
需要让 List API 返回足够的信息,使 Card 能做出正确判断:
|
||||||
|
|
||||||
|
**方案 A:List API 返回 `computer_filter`**
|
||||||
|
|
||||||
|
在 `AssistantsToResponse` 中增加 `computer_filter` 字段。
|
||||||
|
需要在 `ToAssistantModel`(DB 读回)时从 V2 sandbox 配置中提取 filter。
|
||||||
|
|
||||||
|
**方案 B:List API 返回 `sandbox_kind`**
|
||||||
|
|
||||||
|
新增一个简化字段 `sandbox_kind`(`"host"` / `"box"` / `["host","box"]` / `null`),
|
||||||
|
Card 用它替代单纯的 `sandbox` 布尔值做判断。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、AgentPicker 的其他问题
|
||||||
|
|
||||||
|
### 6.1 搜索仅前端过滤
|
||||||
|
|
||||||
|
后端 API 支持 `keywords` 参数(搜索 name/description/capabilities/locales),
|
||||||
|
但 AgentPicker 没用,搜索只在已加载的 ≤20 条数据上做前端 filter。
|
||||||
|
|
||||||
|
### 6.2 标签过滤仅前端聚合
|
||||||
|
|
||||||
|
后端有独立的 Tags API (`GET /agent/assistants/tags`),支持权限过滤,
|
||||||
|
但 AgentPicker 没调用,左侧分类列表从已加载的 ≤20 条数据聚合。
|
||||||
|
|
||||||
|
### 6.3 loadedRef 阻止 filter 变化时重新请求
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || loadedRef.current || !window.$app?.openapi) return
|
||||||
|
loadedRef.current = true
|
||||||
|
// ... API call
|
||||||
|
}, [visible, type, is_cn]) // ← 不包含 filter
|
||||||
|
```
|
||||||
|
|
||||||
|
如果调用方在同一会话中改变 filter props,组件不会重新请求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、修复建议
|
||||||
|
|
||||||
|
### 7.1 后端:List API 的 sandbox 布尔值应使用 IsSandbox
|
||||||
|
|
||||||
|
当前 `AssistantsToResponse` 只看 `a.Sandbox != nil`(V1),应同时考虑 V2:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 建议修改
|
||||||
|
hasSandbox := a.Sandbox != nil || a.IsSandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
但问题是 **从 DB 读回的 model 没有填充 IsSandbox**(只有加载时的运行时路径才填充)。
|
||||||
|
需要在 `ToAssistantModel`(convert.go)中增加 V2 检测:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if sandbox, ok := data["sandbox"]; ok && sandbox != nil {
|
||||||
|
version := extractSandboxVersion(sandbox)
|
||||||
|
if version == "2.0" {
|
||||||
|
sb, err := types.ToSandboxV2(sandbox)
|
||||||
|
if err == nil {
|
||||||
|
model.SandboxV2 = sb
|
||||||
|
model.IsSandbox = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb, err := types.ToSandbox(sandbox)
|
||||||
|
if err == nil {
|
||||||
|
model.Sandbox = sb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 `AssistantsToResponse` 中:
|
||||||
|
|
||||||
|
```go
|
||||||
|
hasSandbox := a.Sandbox != nil || a.IsSandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
对于独立 `sandbox.yao` 文件的助手(DB sandbox 列为空),需要额外机制将 sandbox 标记
|
||||||
|
持久化到 DB,或在 List 查询中从运行时 assistant 实例补充 IsSandbox 信息。
|
||||||
|
|
||||||
|
### 7.2 前端:AgentPicker 应正确使用分页和服务端能力
|
||||||
|
|
||||||
|
**方案 A — 使用正确的 pagesize + 滚动加载(推荐)**
|
||||||
|
|
||||||
|
参考助手页面的实现:
|
||||||
|
- pagesize 设为 20-50(不超过 100)
|
||||||
|
- 实现滚动加载更多(参考 `loadMoreData` 模式)
|
||||||
|
- 使用 `keywords` 参数做服务端搜索(带防抖)
|
||||||
|
- 使用 `tags` 参数做服务端标签过滤
|
||||||
|
- 调用 `tags.List()` 获取准确的标签列表
|
||||||
|
- Tags API 调用需传入与助手列表相同的 filter 条件(如 `types`、`automated`)
|
||||||
|
|
||||||
|
**方案 B — 取消 pagesize 上限(不推荐)**
|
||||||
|
|
||||||
|
放宽后端 `pagesize` 限制到 200-500。不推荐,因为:
|
||||||
|
- 数据量大时响应慢
|
||||||
|
- 内存占用高
|
||||||
|
- 不符合分页设计初衷
|
||||||
|
|
||||||
|
### 7.3 前端:助手页面标签应随查询条件动态更新
|
||||||
|
|
||||||
|
当搜索/筛选条件变化时,重新调用 `tags.List()` 并透传 `keywords` 等参数,
|
||||||
|
使标签 Tab 只显示当前条件下有结果的标签。
|
||||||
|
|
||||||
|
### 7.4 后端:Tags API 增加 types 参数支持
|
||||||
|
|
||||||
|
当前 Tags handler 只支持 `type`(单类型),不支持 `types`(多类型 IN 查询)。
|
||||||
|
AgentPicker 在 MC 场景需要 `types: ['assistant', 'robot']`,如果要让 AgentPicker
|
||||||
|
也用 Tags API,需要后端增加 `types` 支持。
|
||||||
|
|
||||||
|
### 7.5 后端:List API 返回 computer_filter
|
||||||
|
|
||||||
|
在 `AssistantsToResponse` 中,从 V2 sandbox 配置提取 `computer_filter` 返回给前端。
|
||||||
|
这样 Card/详情页可以用 `computer_filter.kind` 做准确的锁定判断,
|
||||||
|
与 InputArea 的逻辑对齐。
|
||||||
|
|
||||||
|
需要在 `ToAssistantModel`(DB 读回)时:
|
||||||
|
- 检测 `version: "2.0"` → 解析 V2 配置 → 提取 `filter` 字段
|
||||||
|
- 将 `computer_filter` 放入响应 map
|
||||||
|
|
||||||
|
注意:`computer_filter` 不在 `availableAssistantFields` 白名单中(types.go),
|
||||||
|
也不在 `defaultAssistantFields` 中。它不是 DB 列,无法通过 `select` 参数获取。
|
||||||
|
必须在 `AssistantToResponse` 阶段从解析后的 V2 配置中额外附加到 result map。
|
||||||
|
(`SandboxV2` 和 `ComputerFilter` 标记 `json:"-"`,`json.Marshal` 不会输出它们。)
|
||||||
|
|
||||||
|
### 7.6 前端:Card/详情页使用 computer_filter 替代 docker 判断
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 当前(V1 逻辑)
|
||||||
|
const chatDisabled = data.sandbox === true && !dockerAvailable
|
||||||
|
|
||||||
|
// 应改为
|
||||||
|
const chatDisabled = data.sandbox === true && !hasCompatibleNode(data.computer_filter)
|
||||||
|
```
|
||||||
|
|
||||||
|
`hasCompatibleNode` 应与 InputArea 的工作区兼容性检查对齐,
|
||||||
|
检查 `kind` 是否有匹配的节点能力(`host_exec` / `docker` / `k8s`)。
|
||||||
|
|
||||||
|
### 7.7 后端:pagesize 超限时应返回错误而非静默回落
|
||||||
|
|
||||||
|
当前行为:前端传 `pagesize=200`,后端静默用 20,不报错。
|
||||||
|
建议:handler 预处理中,当 `pagesize > 100` 时直接返回 400 错误,让前端能感知到问题。
|
||||||
|
|
||||||
|
或者至少在响应中返回实际使用的 pagesize(当前已返回),前端应检查
|
||||||
|
`response.pagesize !== requestedPagesize` 的情况。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、影响范围
|
||||||
|
|
||||||
|
| 组件/页面 | 受影响 | 说明 |
|
||||||
|
|-----------|--------|------|
|
||||||
|
| 助手页面 Card | 严重 | 1) V2 sandbox 电脑图标缺失;2) `kind=host` 的助手被错误禁用聊天;3) 搜索后标签 Tab 不更新 |
|
||||||
|
| 助手详情页 | 严重 | 同 Card:V2 电脑图标缺失 + `kind=host` 错误禁用 |
|
||||||
|
| AgentPicker — 聊天框 | 严重 | 只显示 20 条,搜索/分类不完整 |
|
||||||
|
| AgentPicker — MC 身份设定 | 严重 | 只显示 20 条符合条件的,搜索/分类不完整 |
|
||||||
|
| AgentPicker — MC 添加智能体 | 严重 | 同上 |
|
||||||
|
| Chatbox InputArea | 不受影响 | 已正确使用 `computer_filter.kind` + 节点能力匹配 |
|
||||||
|
| GetInfo API | 不受影响 | 已正确使用 IsSandbox + ComputerFilter |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、相关文件
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `openapi/agent/assistant.go` | List/Tags handler,分页参数解析 |
|
||||||
|
| `openapi/agent/types.go` | pagesize 上限、ValidatePagination、BuildAssistantFilter |
|
||||||
|
| `openapi/agent/filter.go` | AssistantsToResponse — sandbox 布尔化 |
|
||||||
|
| `agent/store/xun/assistant.go` | DB 查询、ToAssistantModel、sandbox 列过滤 |
|
||||||
|
| `agent/store/types/types.go` | AssistantModel 定义(Sandbox vs SandboxV2) |
|
||||||
|
| `agent/store/types/convert.go` | ToAssistantModel — 只调用 ToSandbox |
|
||||||
|
| `agent/store/types/sandbox_v2.go` | ToSandboxV2、LoadSandboxConfig |
|
||||||
|
| `agent/assistant/load.go` | 加载时 V1/V2 分支处理 |
|
||||||
|
| `agent/assistant/assistant.go` | GetInfo — 使用 IsSandbox |
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `components/AgentPicker/index.tsx` | 组件实现 — pagesize:200、前端过滤 |
|
||||||
|
| `components/AgentPicker/types.ts` | AgentPickerProps、AgentPickerFilter |
|
||||||
|
| `chatbox/components/InputArea/AgentTag.tsx` | 聊天框调用 — 无 filter |
|
||||||
|
| `pages/mission-control/.../IdentityPanel.tsx` | MC 调用 — filter={types,automated} |
|
||||||
|
| `pages/mission-control/.../AddAgentModal/index.tsx` | MC 调用 — 同上 |
|
||||||
|
| `pages/assistants/index.tsx` | 助手页面 — 正确的分页实现(参考) |
|
||||||
|
| `openapi/agent/assistants.ts` | API 封装 |
|
||||||
|
| `openapi/agent/tags.ts` | Tags API 封装 |
|
||||||
|
| `openapi/agent/types.ts` | AgentFilter 类型定义 |
|
||||||
|
|
@ -305,12 +305,24 @@ func ListAssistantTags(c *gin.Context) {
|
||||||
|
|
||||||
// Parse filter parameters
|
// Parse filter parameters
|
||||||
typeParam := strings.TrimSpace(c.Query("type"))
|
typeParam := strings.TrimSpace(c.Query("type"))
|
||||||
if typeParam == "" {
|
|
||||||
typeParam = "assistant" // Default type
|
|
||||||
}
|
|
||||||
connector := strings.TrimSpace(c.Query("connector"))
|
connector := strings.TrimSpace(c.Query("connector"))
|
||||||
keywords := strings.TrimSpace(c.Query("keywords"))
|
keywords := strings.TrimSpace(c.Query("keywords"))
|
||||||
|
|
||||||
|
// Parse types (multiple, comma-separated for IN query)
|
||||||
|
var types []string
|
||||||
|
if typesParam := c.Query("types"); typesParam != "" {
|
||||||
|
for _, t := range strings.Split(typesParam, ",") {
|
||||||
|
if trimmed := strings.TrimSpace(t); trimmed != "" {
|
||||||
|
types = append(types, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default type only if neither type nor types is specified
|
||||||
|
if typeParam == "" && len(types) == 0 {
|
||||||
|
typeParam = "assistant"
|
||||||
|
}
|
||||||
|
|
||||||
// Parse boolean filters
|
// Parse boolean filters
|
||||||
var builtIn, mentionable, automated *bool
|
var builtIn, mentionable, automated *bool
|
||||||
if builtInParam := c.Query("built_in"); builtInParam != "" {
|
if builtInParam := c.Query("built_in"); builtInParam != "" {
|
||||||
|
|
@ -326,6 +338,7 @@ func ListAssistantTags(c *gin.Context) {
|
||||||
// Build filter
|
// Build filter
|
||||||
filter := BuildAssistantFilter(AssistantFilterParams{
|
filter := BuildAssistantFilter(AssistantFilterParams{
|
||||||
Type: typeParam,
|
Type: typeParam,
|
||||||
|
Types: types,
|
||||||
Connector: connector,
|
Connector: connector,
|
||||||
Keywords: keywords,
|
Keywords: keywords,
|
||||||
BuiltIn: builtIn,
|
BuiltIn: builtIn,
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,9 @@ func AssistantToResponse(assistant *agenttypes.AssistantModel, hasSandbox bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
result["sandbox"] = hasSandbox
|
result["sandbox"] = hasSandbox
|
||||||
|
if assistant.ComputerFilter != nil {
|
||||||
|
result["computer_filter"] = assistant.ComputerFilter
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,7 +195,7 @@ func AssistantsToResponse(assistants []*agenttypes.AssistantModel) []map[string]
|
||||||
|
|
||||||
result := make([]map[string]interface{}, 0, len(assistants))
|
result := make([]map[string]interface{}, 0, len(assistants))
|
||||||
for _, a := range assistants {
|
for _, a := range assistants {
|
||||||
hasSandbox := a.Sandbox != nil
|
hasSandbox := a.Sandbox != nil || a.IsSandbox
|
||||||
FilterBuiltInAssistant(a)
|
FilterBuiltInAssistant(a)
|
||||||
result = append(result, AssistantToResponse(a, hasSandbox))
|
result = append(result, AssistantToResponse(a, hasSandbox))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,9 @@ func resolveHostAssistantID(ctx context.Context, memberID string) (string, *robo
|
||||||
return "", nil, fmt.Errorf("failed to parse robot config: %w", err)
|
return "", nil, fmt.Errorf("failed to parse robot config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var hostID string
|
hostID := robottypes.ResolvePhaseAgent(config, robottypes.PhaseHost)
|
||||||
if config != nil && config.Resources != nil {
|
if hostID == "" {
|
||||||
hostID = config.Resources.GetPhaseAgent(robottypes.PhaseHost)
|
return "", nil, fmt.Errorf("no Host Agent configured for robot %s (set uses.host in agent.yml or resources.phases in robot config)", memberID)
|
||||||
} else {
|
|
||||||
hostID = "__yao." + string(robottypes.PhaseHost)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return hostID, record, nil
|
return hostID, record, nil
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,8 @@ func TestListAssistants(t *testing.T) {
|
||||||
|
|
||||||
t.Run("ListAssistantsSandboxReturnsBool", func(t *testing.T) {
|
t.Run("ListAssistantsSandboxReturnsBool", func(t *testing.T) {
|
||||||
// Verify sandbox field is returned as boolean (not JSON object) in list response
|
// Verify sandbox field is returned as boolean (not JSON object) in list response
|
||||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?pagesize=5&types=assistant", nil)
|
// and V2 sandboxes include computer_filter
|
||||||
|
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?pagesize=20&types=assistant", nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||||
|
|
||||||
|
|
@ -314,13 +315,26 @@ func TestListAssistants(t *testing.T) {
|
||||||
|
|
||||||
data, hasData := response["data"].([]interface{})
|
data, hasData := response["data"].([]interface{})
|
||||||
if hasData && len(data) > 0 {
|
if hasData && len(data) > 0 {
|
||||||
a, ok := data[0].(map[string]interface{})
|
for _, item := range data {
|
||||||
if ok {
|
a, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
sandboxVal, exists := a["sandbox"]
|
sandboxVal, exists := a["sandbox"]
|
||||||
assert.True(t, exists, "sandbox field should be present in default list fields")
|
assert.True(t, exists, "sandbox field should be present")
|
||||||
_, isBool := sandboxVal.(bool)
|
isSandbox, isBool := sandboxVal.(bool)
|
||||||
assert.True(t, isBool, "sandbox should be a boolean value, got %T", sandboxVal)
|
assert.True(t, isBool, "sandbox should be a boolean, got %T", sandboxVal)
|
||||||
t.Logf("sandbox field correctly returned as bool: %v", sandboxVal)
|
|
||||||
|
if isSandbox {
|
||||||
|
// V2 sandbox assistants should have computer_filter if configured
|
||||||
|
if cf, hasCF := a["computer_filter"]; hasCF {
|
||||||
|
cfMap, isMap := cf.(map[string]interface{})
|
||||||
|
assert.True(t, isMap, "computer_filter should be a map when present, got %T", cf)
|
||||||
|
if isMap {
|
||||||
|
t.Logf("Found computer_filter with kind=%v for assistant %v", cfMap["kind"], a["assistant_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -781,6 +795,101 @@ func TestAssistantEdgeCases(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestListAssistantsSandboxV2 tests sandbox V2 identification and computer_filter in responses
|
||||||
|
func TestListAssistantsSandboxV2(t *testing.T) {
|
||||||
|
serverURL := testutils.Prepare(t)
|
||||||
|
defer testutils.Clean()
|
||||||
|
|
||||||
|
baseURL := ""
|
||||||
|
if openapi.Server != nil && openapi.Server.Config != nil {
|
||||||
|
baseURL = openapi.Server.Config.BaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
client := testutils.RegisterTestClient(t, "Agent Sandbox V2 Test Client", []string{"https://localhost/callback"})
|
||||||
|
defer testutils.CleanupTestClient(t, client.ClientID)
|
||||||
|
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||||
|
|
||||||
|
t.Run("ListAssistantsSandboxV2WithComputerFilter", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?sandbox=true&types=assistant", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
data, hasData := response["data"].([]interface{})
|
||||||
|
if hasData {
|
||||||
|
for _, item := range data {
|
||||||
|
a, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sandboxVal, exists := a["sandbox"]
|
||||||
|
assert.True(t, exists, "sandbox field should exist")
|
||||||
|
isSandbox, isBool := sandboxVal.(bool)
|
||||||
|
assert.True(t, isBool, "sandbox should be bool, got %T", sandboxVal)
|
||||||
|
assert.True(t, isSandbox, "sandbox should be true when filtered by sandbox=true")
|
||||||
|
|
||||||
|
if cf, hasCF := a["computer_filter"]; hasCF && cf != nil {
|
||||||
|
cfMap, isMap := cf.(map[string]interface{})
|
||||||
|
assert.True(t, isMap, "computer_filter should be a map, got %T", cf)
|
||||||
|
if isMap {
|
||||||
|
_, hasKind := cfMap["kind"]
|
||||||
|
assert.True(t, hasKind, "computer_filter should contain kind field")
|
||||||
|
t.Logf("V2 sandbox assistant %v has computer_filter.kind=%v", a["assistant_id"], cfMap["kind"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("Checked %d sandbox assistants for V2 computer_filter", len(data))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ListAssistantsComputerFilterInResponse", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?sandbox=true&pagesize=20", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
data, hasData := response["data"].([]interface{})
|
||||||
|
v2Count := 0
|
||||||
|
if hasData {
|
||||||
|
for _, item := range data {
|
||||||
|
a, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cf, hasCF := a["computer_filter"]; hasCF && cf != nil {
|
||||||
|
v2Count++
|
||||||
|
cfMap, isMap := cf.(map[string]interface{})
|
||||||
|
if isMap {
|
||||||
|
t.Logf("Assistant %v: computer_filter=%v", a["assistant_id"], cfMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("Found %d assistants with computer_filter out of %d sandbox assistants", v2Count, len(data))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestListAssistantTags tests the assistant tags endpoint
|
// TestListAssistantTags tests the assistant tags endpoint
|
||||||
func TestListAssistantTags(t *testing.T) {
|
func TestListAssistantTags(t *testing.T) {
|
||||||
serverURL := testutils.Prepare(t)
|
serverURL := testutils.Prepare(t)
|
||||||
|
|
@ -944,6 +1053,51 @@ func TestListAssistantTags(t *testing.T) {
|
||||||
t.Logf("Successfully retrieved %d tags with keywords filter", len(tags))
|
t.Logf("Successfully retrieved %d tags with keywords filter", len(tags))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("ListAssistantTagsWithTypesFilter", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants/tags?types=assistant,robot", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var tags []interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&tags)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
if len(tags) > 0 {
|
||||||
|
tag, ok := tags[0].(map[string]interface{})
|
||||||
|
if ok {
|
||||||
|
assert.Contains(t, tag, "value", "Tag should have value field")
|
||||||
|
assert.Contains(t, tag, "label", "Tag should have label field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("Successfully retrieved %d tags with types=assistant,robot", len(tags))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ListAssistantTagsWithTypesAndKeywords", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants/tags?types=assistant,robot&keywords=test", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var tags []interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&tags)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
t.Logf("Successfully retrieved %d tags with types+keywords", len(tags))
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("ListAssistantTagsUnauthorized", func(t *testing.T) {
|
t.Run("ListAssistantTagsUnauthorized", func(t *testing.T) {
|
||||||
// Test without authentication
|
// Test without authentication
|
||||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants/tags", nil)
|
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants/tags", nil)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue