diff --git a/agent/robot/api/robot.go b/agent/robot/api/robot.go index 2e4159d9..dc7b918b 100644 --- a/agent/robot/api/robot.go +++ b/agent/robot/api/robot.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + gonanoid "github.com/matoous/go-nanoid/v2" "github.com/yaoapp/gou/model" "github.com/yaoapp/kun/maps" "github.com/yaoapp/yao/agent/robot/store" @@ -282,11 +283,9 @@ func paginateRobots(robots []*types.Robot, query *ListQuery) *ListResult { // CreateRobot creates a new robot member // Calls store.RobotStore.Save() and refreshes cache +// If member_id is not provided, it will be auto-generated func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, error) { // Validate required fields - if req.MemberID == "" { - return nil, fmt.Errorf("member_id is required") - } if req.TeamID == "" { return nil, fmt.Errorf("team_id is required") } @@ -294,6 +293,15 @@ func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, e return nil, fmt.Errorf("display_name is required") } + // Generate member_id if not provided + if req.MemberID == "" { + generatedID, err := generateMemberID(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to generate member_id: %w", err) + } + req.MemberID = generatedID + } + // Check if robot already exists existing, err := robotStore.Get(context.Background(), req.MemberID) if err != nil { @@ -376,10 +384,12 @@ func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, e } // Refresh cache if manager is running + // Use Refresh() which handles autonomous_mode correctly: + // - If autonomous_mode=true: adds to cache for scheduling + // - If autonomous_mode=false: does not add to cache mgr, err := getManager() if err == nil && mgr != nil { - // Load the new robot into cache - _, _ = mgr.Cache().LoadByID(ctx, req.MemberID) + _ = mgr.Cache().Refresh(ctx, req.MemberID) } // Return the created robot as response @@ -486,11 +496,12 @@ func UpdateRobot(ctx *types.Context, memberID string, req *UpdateRobotRequest) ( } // Refresh cache if manager is running + // Use Refresh() which handles autonomous_mode correctly: + // - If autonomous_mode=true: adds to cache for scheduling + // - If autonomous_mode=false: removes from cache mgr, err := getManager() if err == nil && mgr != nil { - // Remove old entry and reload - mgr.Cache().Remove(memberID) - _, _ = mgr.Cache().LoadByID(ctx, memberID) + _ = mgr.Cache().Refresh(ctx, memberID) // Ignore error, database is already saved } // Return the updated robot as response @@ -585,3 +596,54 @@ func recordToResponse(record *store.RobotRecord) *RobotResponse { UpdatedAt: record.UpdatedAt, } } + +// ==================== Member ID Generation ==================== + +// generateMemberID generates a unique member_id with collision detection +// Uses 12-digit numeric ID to match existing pattern in openapi/oauth/providers/user +func generateMemberID(ctx context.Context) (string, error) { + const maxRetries = 10 + + for i := 0; i < maxRetries; i++ { + // Generate 12-digit numeric ID + id, err := gonanoid.Generate("0123456789", 12) + if err != nil { + return "", fmt.Errorf("failed to generate member_id: %w", err) + } + + // Check if ID already exists + exists, err := memberIDExists(ctx, id) + if err != nil { + return "", fmt.Errorf("failed to check member_id existence: %w", err) + } + + if !exists { + return id, nil + } + // ID exists, retry + } + + return "", fmt.Errorf("failed to generate unique member_id after %d retries", maxRetries) +} + +// memberIDExists checks if a member_id already exists in the database +func memberIDExists(ctx context.Context, memberID string) (bool, error) { + m := model.Select(memberModel) + if m == nil { + return false, fmt.Errorf("model %s not found", memberModel) + } + + members, err := m.Get(model.QueryParam{ + Select: []interface{}{"id"}, + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: memberID}, + }, + Limit: 1, + }) + + if err != nil { + return false, err + } + + return len(members) > 0, nil +} diff --git a/agent/robot/api/robot_test.go b/agent/robot/api/robot_test.go index 02607de8..5e8188a4 100644 --- a/agent/robot/api/robot_test.go +++ b/agent/robot/api/robot_test.go @@ -115,16 +115,22 @@ func TestCreateRobotValidation(t *testing.T) { ctx := types.NewContext(context.Background(), nil) - t.Run("returns_error_for_empty_member_id", func(t *testing.T) { + t.Run("auto_generates_member_id_when_empty", func(t *testing.T) { req := &api.CreateRobotRequest{ MemberID: "", TeamID: "team_001", - DisplayName: "Test Robot", + DisplayName: "Test Robot Auto ID", } result, err := api.CreateRobot(ctx, req) - assert.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "member_id is required") + require.NoError(t, err) + require.NotNil(t, result) + + // Verify member_id was auto-generated (12-digit numeric) + assert.NotEmpty(t, result.MemberID) + assert.Len(t, result.MemberID, 12, "Auto-generated member_id should be 12 digits") + + // Cleanup + _ = api.RemoveRobot(ctx, result.MemberID) }) t.Run("returns_error_for_empty_team_id", func(t *testing.T) { diff --git a/agent/robot/api/types.go b/agent/robot/api/types.go index 9858e57d..22154dcd 100644 --- a/agent/robot/api/types.go +++ b/agent/robot/api/types.go @@ -120,9 +120,9 @@ type AuthScope struct { // CreateRobotRequest - request for CreateRobot() type CreateRobotRequest struct { - // Required fields - MemberID string `json:"member_id"` // Unique robot identifier - TeamID string `json:"team_id"` // Team ID + // Identity (member_id is optional - auto-generated if not provided) + MemberID string `json:"member_id,omitempty"` // Unique robot identifier (auto-generated if empty) + TeamID string `json:"team_id"` // Team ID (required) // Profile DisplayName string `json:"display_name,omitempty"` // Display name diff --git a/agent/robot/manager/manager.go b/agent/robot/manager/manager.go index 38731efc..ed7a2a45 100644 --- a/agent/robot/manager/manager.go +++ b/agent/robot/manager/manager.go @@ -381,6 +381,7 @@ func (m *Manager) matchesDay(clock *types.Clock, now time.Time) bool { // TriggerManual manually triggers a robot execution (for testing or API calls) // This bypasses clock checking and directly submits to pool +// For non-autonomous robots: lazy-loads from DB, executes, then unloads func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger types.TriggerType, data interface{}) (string, error) { m.mu.RLock() if !m.started { @@ -389,10 +390,10 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ } m.mu.RUnlock() - // Get robot from cache - robot := m.cache.Get(memberID) - if robot == nil { - return "", types.ErrRobotNotFound + // Get robot from cache, or lazy-load if not found + robot, lazyLoaded, err := m.getOrLoadRobot(ctx, memberID) + if err != nil { + return "", err } // Check robot status @@ -410,9 +411,18 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ // Submit to pool execID, err := m.pool.Submit(ctx, robot, trigger, data) if err != nil { + // If lazy-loaded and submission failed, remove from cache + if lazyLoaded { + m.cache.Remove(memberID) + } return "", err } + // For lazy-loaded robots, schedule cleanup after execution completes + if lazyLoaded { + m.scheduleCleanup(robot) + } + return execID, nil } @@ -420,6 +430,7 @@ func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger typ // Intervene processes a human intervention request // Human intervention skips P0 (inspiration) and goes directly to P1 (goals) +// For non-autonomous robots: lazy-loads from DB, executes, then unloads func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) { m.mu.RLock() if !m.started { @@ -433,10 +444,10 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t return nil, err } - // Get robot from cache - robot := m.cache.Get(req.MemberID) - if robot == nil { - return nil, types.ErrRobotNotFound + // Get robot from cache, or lazy-load if not found + robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID) + if err != nil { + return nil, err } // Check robot status @@ -460,6 +471,10 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t // Handle plan.add action - schedule for later if req.Action == types.ActionPlanAdd && req.PlanTime != nil { + // If lazy-loaded but not executing, remove immediately + if lazyLoaded { + m.cache.Remove(req.MemberID) + } // TODO: Add to plan queue (Phase 11.3) return &types.ExecutionResult{ Status: types.ExecPending, @@ -473,12 +488,21 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t // Submit to pool with executor mode execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerHuman, triggerInput, executorMode) if err != nil { + // If lazy-loaded and submission failed, remove from cache + if lazyLoaded { + m.cache.Remove(req.MemberID) + } return nil, err } // Track execution for pause/resume/stop m.execController.Track(execID, req.MemberID, req.TeamID) + // For lazy-loaded robots, schedule cleanup after execution completes + if lazyLoaded { + m.scheduleCleanup(robot) + } + return &types.ExecutionResult{ ExecutionID: execID, Status: types.ExecPending, @@ -488,6 +512,7 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t // HandleEvent processes an event trigger request // Event trigger skips P0 (inspiration) and goes directly to P1 (goals) +// For non-autonomous robots: lazy-loads from DB, executes, then unloads func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) { m.mu.RLock() if !m.started { @@ -501,10 +526,10 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ return nil, err } - // Get robot from cache - robot := m.cache.Get(req.MemberID) - if robot == nil { - return nil, types.ErrRobotNotFound + // Get robot from cache, or lazy-load if not found + robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID) + if err != nil { + return nil, err } // Check robot status @@ -528,12 +553,21 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ // Submit to pool with executor mode execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerEvent, triggerInput, executorMode) if err != nil { + // If lazy-loaded and submission failed, remove from cache + if lazyLoaded { + m.cache.Remove(req.MemberID) + } return nil, err } // Track execution for pause/resume/stop m.execController.Track(execID, req.MemberID, "") + // For lazy-loaded robots, schedule cleanup after execution completes + if lazyLoaded { + m.scheduleCleanup(robot) + } + return &types.ExecutionResult{ ExecutionID: execID, Status: types.ExecPending, @@ -579,6 +613,70 @@ func (m *Manager) ListExecutionsByMember(memberID string) []*trigger.ControlledE // ==================== Helper Methods ==================== +// getOrLoadRobot gets a robot from cache, or lazy-loads from DB if not found +// Returns: robot, wasLazyLoaded, error +func (m *Manager) getOrLoadRobot(ctx *types.Context, memberID string) (*types.Robot, bool, error) { + // Try cache first + robot := m.cache.Get(memberID) + if robot != nil { + return robot, false, nil + } + + // Not in cache - lazy load from database + robot, err := m.cache.LoadByID(ctx, memberID) + if err != nil { + return nil, false, err + } + + // Add to cache temporarily for execution tracking + m.cache.Add(robot) + + // Return with lazyLoaded=true to indicate cleanup needed after execution + return robot, true, nil +} + +// scheduleCleanup schedules removal of a lazy-loaded robot after all executions complete +// This runs in a goroutine that monitors the robot's execution count +func (m *Manager) scheduleCleanup(robot *types.Robot) { + go func() { + memberID := robot.MemberID + + // Poll every 5 seconds to check if all executions are done + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + // Timeout after 24 hours to prevent memory leaks + timeout := time.After(24 * time.Hour) + + for { + select { + case <-timeout: + // Timeout - force cleanup + m.cache.Remove(memberID) + return + + case <-ticker.C: + // Check if robot still exists in cache + r := m.cache.Get(memberID) + if r == nil { + // Already removed + return + } + + // Check if all executions are done + if r.RunningCount() == 0 { + // Only remove if still non-autonomous + // (user might have changed it during execution) + if !r.AutonomousMode { + m.cache.Remove(memberID) + } + return + } + } + } + }() +} + // resolveExecutorMode determines the executor mode to use // Priority: request > robot config > default (standard) func (m *Manager) resolveExecutorMode(requestMode types.ExecutorMode, robot *types.Robot) types.ExecutorMode { diff --git a/agent/robot/manager/manager_test.go b/agent/robot/manager/manager_test.go index bf2f5646..08b32f96 100644 --- a/agent/robot/manager/manager_test.go +++ b/agent/robot/manager/manager_test.go @@ -1397,6 +1397,272 @@ func setupTestRobotsWithEventConfig(t *testing.T) { } } +// ==================== Lazy Load Tests for Non-Autonomous Robots ==================== + +// TestManagerLazyLoadNonAutonomous tests that non-autonomous robots are lazy-loaded on demand +// and automatically cleaned up after execution completes +func TestManagerLazyLoadNonAutonomous(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + setupTestRobotsWithNonAutonomous(t) + defer cleanupTestRobots(t) + + t.Run("non-autonomous robot not in cache on startup", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + // Non-autonomous robot should NOT be in cache + robot := m.Cache().Get("robot_test_manager_on_demand") + assert.Nil(t, robot, "Non-autonomous robot should not be pre-loaded into cache") + + // Autonomous robot SHOULD be in cache + autoRobot := m.Cache().Get("robot_test_manager_times") + assert.NotNil(t, autoRobot, "Autonomous robot should be in cache") + }) + + t.Run("TriggerManual lazy-loads non-autonomous robot", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + ctx := types.NewContext(context.Background(), nil) + + // Verify robot is NOT in cache before trigger + assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand")) + + // Trigger the non-autonomous robot manually + execID, err := m.TriggerManual(ctx, "robot_test_manager_on_demand", types.TriggerHuman, nil) + assert.NoError(t, err) + assert.NotEmpty(t, execID) + + // Robot should now be in cache (lazy-loaded) + robot := m.Cache().Get("robot_test_manager_on_demand") + assert.NotNil(t, robot, "Robot should be lazy-loaded into cache") + assert.Equal(t, "robot_test_manager_on_demand", robot.MemberID) + assert.False(t, robot.AutonomousMode) + }) + + t.Run("Intervene lazy-loads non-autonomous robot", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + ctx := types.NewContext(context.Background(), nil) + + // Verify robot is NOT in cache before trigger + assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand_intervene")) + + // Intervene on the non-autonomous robot + req := &types.InterveneRequest{ + TeamID: "team_test_manager", + MemberID: "robot_test_manager_on_demand_intervene", + Action: types.ActionTaskAdd, + Messages: []agentcontext.Message{ + {Role: agentcontext.RoleUser, Content: "Test lazy load via intervene"}, + }, + } + + result, err := m.Intervene(ctx, req) + assert.NoError(t, err) + assert.NotEmpty(t, result.ExecutionID) + + // Robot should now be in cache (lazy-loaded) + robot := m.Cache().Get("robot_test_manager_on_demand_intervene") + assert.NotNil(t, robot, "Robot should be lazy-loaded into cache via Intervene") + }) + + t.Run("HandleEvent lazy-loads non-autonomous robot", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + ctx := types.NewContext(context.Background(), nil) + + // Verify robot is NOT in cache before trigger + assert.Nil(t, m.Cache().Get("robot_test_manager_on_demand_event")) + + // Send event to the non-autonomous robot + req := &types.EventRequest{ + MemberID: "robot_test_manager_on_demand_event", + Source: "webhook", + EventType: "data.updated", + Data: map[string]interface{}{"test": true}, + } + + result, err := m.HandleEvent(ctx, req) + assert.NoError(t, err) + assert.NotEmpty(t, result.ExecutionID) + + // Robot should now be in cache (lazy-loaded) + robot := m.Cache().Get("robot_test_manager_on_demand_event") + assert.NotNil(t, robot, "Robot should be lazy-loaded into cache via HandleEvent") + }) + + t.Run("lazy-loaded robot is cleaned up after execution completes", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + ctx := types.NewContext(context.Background(), nil) + + // Trigger the non-autonomous robot + _, err = m.TriggerManual(ctx, "robot_test_manager_on_demand", types.TriggerHuman, nil) + assert.NoError(t, err) + + // Robot should be in cache immediately after trigger + robot := m.Cache().Get("robot_test_manager_on_demand") + assert.NotNil(t, robot, "Robot should be in cache after trigger") + + // Wait for execution to complete and cleanup to happen + // The stub executor completes quickly, and cleanup runs every 5 seconds + // We wait up to 10 seconds for the cleanup goroutine to remove the robot + var removed bool + for i := 0; i < 20; i++ { + time.Sleep(500 * time.Millisecond) + if m.Cache().Get("robot_test_manager_on_demand") == nil { + removed = true + break + } + } + + assert.True(t, removed, "Non-autonomous robot should be removed from cache after execution completes") + }) + + t.Run("trigger non-existent robot returns error", func(t *testing.T) { + m := manager.New() + err := m.Start() + assert.NoError(t, err) + defer m.Stop() + + ctx := types.NewContext(context.Background(), nil) + + // Try to trigger a robot that doesn't exist in DB + _, err = m.TriggerManual(ctx, "robot_nonexistent_xyz", types.TriggerHuman, nil) + assert.Error(t, err) + assert.Equal(t, types.ErrRobotNotFound, err) + }) +} + +// setupTestRobotsWithNonAutonomous creates test robots including non-autonomous ones +func setupTestRobotsWithNonAutonomous(t *testing.T) { + // First setup the autonomous robots + setupTestRobotsWithClockConfig(t) + + // Add non-autonomous robots + qb := capsule.Query() + m := model.Select("__yao.member") + tableName := m.MetaData.Table.Name + + // Non-autonomous robot 1: for TriggerManual test + robotConfigOnDemand := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "On-Demand Robot", + }, + "triggers": map[string]interface{}{ + "clock": map[string]interface{}{"enabled": false}, + "intervene": map[string]interface{}{"enabled": true}, + }, + "quota": map[string]interface{}{ + "max": 2, + "queue": 5, + }, + } + configOnDemandJSON, _ := json.Marshal(robotConfigOnDemand) + + err := qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_manager_on_demand", + "team_id": "team_test_manager", + "member_type": "robot", + "display_name": "Test On-Demand Robot", + "status": "active", + "role_id": "member", + "autonomous_mode": false, // Non-autonomous! + "robot_status": "idle", + "robot_config": string(configOnDemandJSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_manager_on_demand: %v", err) + } + + // Non-autonomous robot 2: for Intervene test + robotConfigOnDemandIntervene := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "On-Demand Intervene Robot", + }, + "triggers": map[string]interface{}{ + "clock": map[string]interface{}{"enabled": false}, + "intervene": map[string]interface{}{"enabled": true}, + }, + "quota": map[string]interface{}{ + "max": 2, + }, + } + configOnDemandInterveneJSON, _ := json.Marshal(robotConfigOnDemandIntervene) + + err = qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_manager_on_demand_intervene", + "team_id": "team_test_manager", + "member_type": "robot", + "display_name": "Test On-Demand Intervene Robot", + "status": "active", + "role_id": "member", + "autonomous_mode": false, // Non-autonomous! + "robot_status": "idle", + "robot_config": string(configOnDemandInterveneJSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_manager_on_demand_intervene: %v", err) + } + + // Non-autonomous robot 3: for HandleEvent test + robotConfigOnDemandEvent := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "On-Demand Event Robot", + }, + "triggers": map[string]interface{}{ + "clock": map[string]interface{}{"enabled": false}, + "event": map[string]interface{}{"enabled": true}, + }, + "quota": map[string]interface{}{ + "max": 2, + }, + } + configOnDemandEventJSON, _ := json.Marshal(robotConfigOnDemandEvent) + + err = qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": "robot_test_manager_on_demand_event", + "team_id": "team_test_manager", + "member_type": "robot", + "display_name": "Test On-Demand Event Robot", + "status": "active", + "role_id": "member", + "autonomous_mode": false, // Non-autonomous! + "robot_status": "idle", + "robot_config": string(configOnDemandEventJSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot_test_manager_on_demand_event: %v", err) + } +} + // cleanupTestRobots removes all test robot records func cleanupTestRobots(t *testing.T) { qb := capsule.Query() @@ -1417,6 +1683,10 @@ func cleanupTestRobots(t *testing.T) { "robot_test_manager_intervene_disabled", "robot_test_manager_event", "robot_test_manager_event_disabled", + // Non-autonomous robots + "robot_test_manager_on_demand", + "robot_test_manager_on_demand_intervene", + "robot_test_manager_on_demand_event", } for _, id := range testRobotIDs { diff --git a/engine/load.go b/engine/load.go index 873de786..1f8bcc1a 100644 --- a/engine/load.go +++ b/engine/load.go @@ -2,6 +2,7 @@ package engine import ( "fmt" + "log" "os" "regexp" "strings" @@ -12,6 +13,7 @@ import ( "github.com/yaoapp/gou/process" "github.com/yaoapp/kun/exception" "github.com/yaoapp/yao/agent" + robotapi "github.com/yaoapp/yao/agent/robot/api" "github.com/yaoapp/yao/aigc" "github.com/yaoapp/yao/api" "github.com/yaoapp/yao/attachment" @@ -355,6 +357,17 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string warnings = append(warnings, Warning{Widget: "Agent", Error: err}) } + // Start Robot Agent System (async, non-blocking) + // This starts the robot scheduler for autonomous mode robots + go func() { + if err := robotapi.Start(); err != nil { + // Log warning but don't block application startup + // The robot system can operate without the manager running + // (API calls will fall back to direct database queries) + log.Printf("[Robot Agent] Warning: failed to start robot agent system: %v", err) + } + }() + for name, hook := range LoadHooks { err = hook(cfg) if err != nil { @@ -396,6 +409,13 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string func Unload() (err error) { defer func() { err = exception.Catch(recover()) }() + // Stop Robot Agent System + if robotapi.IsRunning() { + if stopErr := robotapi.Stop(); stopErr != nil { + log.Printf("[Robot Agent] Warning: failed to stop robot agent system: %v", stopErr) + } + } + // Stop Runtime err = runtime.Stop() diff --git a/openapi/agent/robot/TODO.md b/openapi/agent/robot/TODO.md index 20a5e617..c685f0a5 100644 --- a/openapi/agent/robot/TODO.md +++ b/openapi/agent/robot/TODO.md @@ -21,6 +21,14 @@ └─ Page Integration (Robot list, detail, create, edit, delete) ✅ └─ UI/UX (CreatureLoading, bubble animations) ✅ +✅ Phase 1.5: Robot Manager Lifecycle ✅ [Completed] + └─ Auto-start Manager on Yao startup (async) + └─ Auto-reload cache on robot update + └─ Auto-remove from cache on robot delete + └─ Graceful shutdown on Yao unload + └─ Lazy-load for non-autonomous robots (load on trigger, unload after execution) + └─ Unit tests: TestManagerLazyLoadNonAutonomous (6 test cases) + 🟢 Phase 2: Execution Management Backend → SDK → Page Integration └─ List, Get, Control executions, Trigger/Intervene @@ -65,6 +73,7 @@ #### API Layer (Thin wrappers calling store) - [x] Implement `api.CreateRobot()` - call `store.RobotStore.Save()` + cache refresh + - [x] Auto-generate `member_id` if not provided (12-digit numeric, matches existing pattern) - [x] Implement `api.UpdateRobot()` - partial update + cache refresh - [x] Implement `api.RemoveRobot()` - call `store.RobotStore.Delete()` + cache invalidate - [x] Implement `api.GetRobotResponse()` - get robot as API response @@ -118,6 +127,7 @@ - [x] POST /v1/agent/robots handler - [x] Parse HTTP request to `CreateRobotRequest` +- [x] Auto-generate `member_id` if not provided (12-digit numeric, consistent with existing API) - [x] Apply `AuthScope` with permission fields (CreatedBy, TeamID, TenantID) - [x] Call `robot/api.CreateRobot()` - [x] Return created robot (201 Created) @@ -621,6 +631,7 @@ yao/openapi/tests/robot/ |-------|------|---------|----------|-------------| | 1. Core CRUD | 🟢 | ✅ | ✅ | Robot CRUD endpoints | | 1-FE Frontend Integration | 🟢 | - | ✅ | SDK ✅, Page Integration ✅, UI/UX ✅ | +| 1.5 Manager Lifecycle | 🟢 | ✅ | - | Auto-start, auto-reload, graceful shutdown | | 2. Execution | 🟢 | ⬜ | ⬜ | Execution listing, control, trigger | | 3. Results/Activities | 🟢 | ⬜ | ⬜ | Deliverables and activity feed | | 4. i18n | 🟢 | ⬜ | ⬜ | Locale parameter support | diff --git a/openapi/agent/robot/detail.go b/openapi/agent/robot/detail.go index ed2fa261..9bb796d0 100644 --- a/openapi/agent/robot/detail.go +++ b/openapi/agent/robot/detail.go @@ -148,14 +148,6 @@ func CreateRobot(c *gin.Context) { } // Validate required fields - if req.MemberID == "" { - errorResp := &response.ErrorResponse{ - Code: response.ErrInvalidRequest.Code, - ErrorDescription: "member_id is required", - } - response.RespondWithError(c, response.StatusBadRequest, errorResp) - return - } if req.DisplayName == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, @@ -165,6 +157,21 @@ func CreateRobot(c *gin.Context) { return } + // Generate member_id if not provided (follows existing API pattern) + if req.MemberID == "" { + generatedID, err := GenerateMemberID(c.Request.Context()) + if err != nil { + log.Error("Failed to generate member_id: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to generate member_id: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + req.MemberID = generatedID + } + // Determine effective team_id: // - If user has a team selected (authInfo.TeamID), use it // - Otherwise, for personal users, use user_id as team_id diff --git a/openapi/agent/robot/types.go b/openapi/agent/robot/types.go index 01d9d352..7dd328de 100644 --- a/openapi/agent/robot/types.go +++ b/openapi/agent/robot/types.go @@ -10,9 +10,9 @@ import ( // CreateRobotRequest - HTTP request for creating a robot type CreateRobotRequest struct { - // Required fields - MemberID string `json:"member_id" binding:"required"` // Unique robot identifier - TeamID string `json:"team_id" binding:"required"` // Team ID + // Identity (member_id is optional - auto-generated if not provided) + MemberID string `json:"member_id,omitempty"` // Unique robot identifier (optional, auto-generated if empty) + TeamID string `json:"team_id,omitempty"` // Team ID (optional, defaults to auth team or user_id) // Profile DisplayName string `json:"display_name" binding:"required"` // Display name diff --git a/openapi/agent/robot/utils.go b/openapi/agent/robot/utils.go index 639c756e..28c20175 100644 --- a/openapi/agent/robot/utils.go +++ b/openapi/agent/robot/utils.go @@ -1,9 +1,13 @@ package robot import ( + "context" + "fmt" "strings" "github.com/gin-gonic/gin" + gonanoid "github.com/matoous/go-nanoid/v2" + "github.com/yaoapp/gou/model" ) // GetLocale extracts locale from request @@ -41,3 +45,57 @@ func ParseBoolValue(value string) *bool { } return nil } + +// ==================== Member ID Generation ==================== +// Follows the same pattern as openapi/oauth/providers/user/utils.go + +const memberModel = "__yao.member" + +// GenerateMemberID generates a new unique member_id for robot creation +// Uses numeric ID (12 characters) with collision detection +func GenerateMemberID(ctx context.Context) (string, error) { + const maxRetries = 10 + + for i := 0; i < maxRetries; i++ { + // Generate 12-digit numeric ID (matches existing pattern) + id, err := gonanoid.Generate("0123456789", 12) + if err != nil { + return "", fmt.Errorf("failed to generate member_id: %w", err) + } + + // Check if ID already exists + exists, err := memberIDExists(ctx, id) + if err != nil { + return "", fmt.Errorf("failed to check member_id existence: %w", err) + } + + if !exists { + return id, nil + } + // ID exists, retry + } + + return "", fmt.Errorf("failed to generate unique member_id after %d retries", maxRetries) +} + +// memberIDExists checks if a member_id already exists in the database +func memberIDExists(ctx context.Context, memberID string) (bool, error) { + m := model.Select(memberModel) + if m == nil { + return false, fmt.Errorf("model %s not found", memberModel) + } + + members, err := m.Get(model.QueryParam{ + Select: []interface{}{"id"}, + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: memberID}, + }, + Limit: 1, + }) + + if err != nil { + return false, err + } + + return len(members) > 0, nil +}