diff --git a/agent/assistant/load_system.go b/agent/assistant/load_system.go index 69e1f62b..e8e2c187 100644 --- a/agent/assistant/load_system.go +++ b/agent/assistant/load_system.go @@ -24,6 +24,7 @@ var systemAgents = []string{ "querydsl", "title", "prompt", + "robot_prompt", "needsearch", "entity", } @@ -31,13 +32,14 @@ var systemAgents = []string{ // SystemConfig holds the system agents connector configuration // This is set from agent.yml system block type SystemConfig struct { - Default string // Default connector for all system agents - Keyword string // Connector for __yao.keyword agent - QueryDSL string // Connector for __yao.querydsl agent - Title string // Connector for __yao.title agent - Prompt string // Connector for __yao.prompt agent - NeedSearch string // Connector for __yao.needsearch agent - Entity string // Connector for __yao.entity agent + Default string // Default connector for all system agents + Keyword string // Connector for __yao.keyword agent + QueryDSL string // Connector for __yao.querydsl agent + Title string // Connector for __yao.title agent + Prompt string // Connector for __yao.prompt agent + RobotPrompt string // Connector for __yao.robot_prompt agent + NeedSearch string // Connector for __yao.needsearch agent + Entity string // Connector for __yao.entity agent } // systemConfig holds the system agents configuration (global variable like others in load.go) @@ -223,6 +225,10 @@ func resolveSystemConnector(agentID string) string { if systemConfig.Prompt != "" { return systemConfig.Prompt } + case "__yao.robot_prompt": + if systemConfig.RobotPrompt != "" { + return systemConfig.RobotPrompt + } case "__yao.needsearch": if systemConfig.NeedSearch != "" { return systemConfig.NeedSearch diff --git a/agent/load.go b/agent/load.go index dd168c5e..b554ea7d 100644 --- a/agent/load.go +++ b/agent/load.go @@ -52,14 +52,19 @@ func Load(cfg config.Config) error { setting.Uses = &types.Uses{Default: "mohe"} // Agent is the developer name, Mohe is the brand name of the assistant } - // Title Assistant + // Title Assistant (default to system agent) if setting.Uses.Title == "" { - setting.Uses.Title = setting.Uses.Default + setting.Uses.Title = "__yao.title" } - // Prompt Assistant + // Prompt Assistant (default to system agent) if setting.Uses.Prompt == "" { - setting.Uses.Prompt = setting.Uses.Default + setting.Uses.Prompt = "__yao.prompt" + } + + // RobotPrompt Assistant (default to system agent) + if setting.Uses.RobotPrompt == "" { + setting.Uses.RobotPrompt = "__yao.robot_prompt" } agentDSL = &setting diff --git a/agent/robot/DESIGN.md b/agent/robot/DESIGN.md index 431307f2..113e9bf1 100644 --- a/agent/robot/DESIGN.md +++ b/agent/robot/DESIGN.md @@ -60,7 +60,6 @@ flowchart TB subgraph Storage["Storage"] KB[("KB")] DB[("DB")] - Job[("Job")] end WC --> TC @@ -75,7 +74,7 @@ flowchart TB TT -->|Clock| P0 TT -->|Human/Event| P1 P0 --> P1 --> P2 --> P3 --> P4 --> P5 - P5 --> KB & DB & Job + P5 --> KB & DB KB -.->|History| P0 ``` @@ -89,7 +88,7 @@ Executor supports multiple execution modes for different use cases: | DryRun | Tests, demos, preview without LLM calls | ✅ Implemented | | Sandbox | Container-isolated for untrusted code | ⬜ Not Implemented | -**Standard Mode:** Real execution with LLM calls, Job integration, full phase execution. +**Standard Mode:** Real execution with LLM calls, full phase execution, logging via kun/log. **DryRun Mode:** Simulated execution without LLM calls. Used for: @@ -1009,15 +1008,13 @@ stateDiagram-v2 2. Generate member_id if missing 3. Create KB: `robot_{team_id}_{member_id}_kb` 4. Add to cache -5. Create Job -6. Set active +5. Set active ### 6.3 On Delete -1. Stop running jobs +1. Stop running executions 2. Remove from cache -3. Delete Job -4. Delete or archive KB +3. Delete or archive KB 5. Soft delete record ### 6.4 Execution Flow @@ -1065,35 +1062,19 @@ stateDiagram-v2 ## 7. Integrations -### 7.1 Job System +### 7.1 Execution Storage -**Relationship:** 1 Robot : N Executions (concurrent), 1 Execution = 1 job.Job +**Relationship:** 1 Robot : N Executions (concurrent) -Each trigger creates a new Execution, mapped to a `job.Job` for monitoring. +Each trigger creates a new Execution, stored in `ExecutionStore` (`__yao.agent_execution` table). -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Activity Monitor (UI) │ -│ • List jobs │ -│ • See progress │ -│ • View logs │ -│ • Cancel/retry │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Job Framework │ -│ Job → Execution → Progress → Logs │ -└─────────────────────────────────────────────────────────────────┘ -``` +Execution data includes: +- Status and phase tracking +- All phase outputs (Inspiration, Goals, Tasks, Results, Delivery, Learning) +- Error information +- Timestamps and progress -**Go APIs (yao/job package):** - -| Action | API | -| ------------ | -------------------------------------------------------- | -| List Jobs | `job.ListJobs(param, page, pagesize)` | -| Get Job | `job.GetJob(jobID, param)` | -| Save Job | `job.SaveJob(j)` | +Logging is handled by `kun/log` package for standard application logging. | List Execs | `job.ListExecutions(param, page, pagesize)` | | Get Exec | `job.GetExecution(execID, param)` | | Save Exec | `job.SaveExecution(exec)` | @@ -1247,78 +1228,49 @@ type RobotState struct { } ``` -### 8.3 Execution (Uses Job System) +### 8.3 Execution (Uses ExecutionStore) -No separate `autonomous_executions` table. Uses existing Job system. +Uses dedicated `__yao.agent_execution` table via ExecutionStore. -**Each trigger creates a new job.Job:** +**Each trigger creates a new Execution:** ```go -// On each trigger (clock/human/event), create a new Job -execID := gonanoid.Must() -j, _ := job.Once(job.GOROUTINE, map[string]interface{}{ - "job_id": "robot_exec_" + execID, // unique per execution - "category_id": "autonomous_robot", - "name": fmt.Sprintf("%s - %s", member.DisplayName, triggerType), - "metadata": map[string]interface{}{ - "member_id": memberID, - "team_id": teamID, - "trigger_type": triggerType, - "exec_id": execID, - }, -}) -job.SaveJob(j) - -// Configure and start -j.ExecutionConfig = &job.ExecutionConfig{ - Type: job.ExecutionTypeProcess, - ProcessName: "robot.Execute", - ProcessArgs: []interface{}{memberID, execID, triggerData}, +// On each trigger (clock/human/event), create a new Execution +exec := &types.Execution{ + ID: utils.NewID(), + MemberID: memberID, + TeamID: teamID, + TriggerType: triggerType, + Status: types.ExecStatusRunning, + Phase: types.PhaseP0Init, + StartedAt: time.Now(), } -j.Push() + +// Save to ExecutionStore +execStore.Save(exec) ``` **Query executions for a robot:** ```go // List all executions for a robot member -param := model.QueryParam{ - Wheres: []model.QueryWhere{ - {Column: "category_id", Value: "autonomous_robot"}, - {Column: "metadata->member_id", Value: memberID}, - }, - Orders: []model.QueryOrder{{Column: "created_at", Option: "desc"}}, -} -jobs, _ := job.ListJobs(param, 1, 10) +executions, err := execStore.List(memberID, 1, 10) ``` **Query examples:** ```go -// List all robot jobs (all robots, all executions) -param := model.QueryParam{ - Wheres: []model.QueryWhere{ - {Column: "category_id", Value: "autonomous_robot"}, - }, -} -jobs, _ := job.ListJobs(param, 1, 20) +// Get execution by ID +exec, err := execStore.Get(executionID) -// Get executions for a robot -execParam := model.QueryParam{ - Wheres: []model.QueryWhere{ - {Column: "job_id", Value: "robot_" + memberID}, - }, - Orders: []model.QueryOrder{{Column: "created_at", Option: "desc"}}, -} -execs, _ := job.ListExecutions(execParam, 1, 10) +// List executions for a robot +executions, err := execStore.List(memberID, page, pageSize) -// Get logs for an execution -logParam := model.QueryParam{ - Wheres: []model.QueryWhere{ - {Column: "execution_id", Value: execID}, - }, -} -logs, _ := job.ListLogs(logParam, 1, 100) +// Update execution status +execStore.UpdateStatus(executionID, types.ExecStatusCompleted) + +// Logging via kun/log +log.With(log.F{"execution_id": exec.ID, "phase": "P1"}).Info("Phase started") ``` --- diff --git a/agent/robot/TECHNICAL.md b/agent/robot/TECHNICAL.md index 4578e2c5..7067e841 100644 --- a/agent/robot/TECHNICAL.md +++ b/agent/robot/TECHNICAL.md @@ -82,11 +82,6 @@ yao/agent/robot/ │ ├── db.go # Database queries │ └── learning.go # Learning entry save (to KB) │ -├── job/ # Job system integration -│ ├── job.go # Create/Get job for robot -│ ├── execution.go # Create/Update execution -│ └── log.go # Write execution logs -│ └── plan/ # Plan queue (deferred tasks) ├── plan.go # Plan queue struct └── schedule.go # Schedule for later @@ -111,7 +106,7 @@ yao/assert/ # Universal assertion library (global package) │ │ │ │ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ┌───────┐┌───────┐┌───────┐┌──────┐┌────┐┌──────┐┌───────┐┌─────────┐ -│ cache ││ dedup ││ store ││ pool ││job ││ plan ││ utils ││ trigger │ +│ cache ││ dedup ││ store ││ pool ││ plan ││ utils ││ trigger │ └───┬───┘└───┬───┘└───┬───┘└──┬───┘└──┬─┘└──────┘└───────┘└────┬────┘ │ │ │ │ │ │ └────────┴────────┴───────┴───────┴────────────────────────┘ @@ -145,9 +140,8 @@ yao/assert/ # Universal assertion library (global package) | `store/` | `types/` | | `pool/` | `types/` | | `trigger/` | `types/` | -| `job/` | `types/`, `yao/job` | | `plan/` | `types/` | -| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `job/`, `yao/assert` | +| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `yao/assert` | | `manager/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` | | | Manager handles all trigger logic (clock, intervene, event) | | `api/` | `types/`, `manager/` | @@ -304,7 +298,6 @@ type TriggerResult struct { Accepted bool `json:"accepted"` // whether trigger was accepted Queued bool `json:"queued"` // true if queued (quota full) Execution *types.Execution `json:"execution,omitempty"` // execution info if started - JobID string `json:"job_id,omitempty"` // job ID for tracking Message string `json:"message,omitempty"` // status message } @@ -524,7 +517,6 @@ interface TriggerResult { accepted: boolean; queued: boolean; execution?: Execution; - job_id?: string; message?: string; } @@ -1160,7 +1152,7 @@ import ( // Robot - runtime representation of an autonomous robot (from __yao.member) // Relationship: 1 Robot : N Executions (concurrent) -// Each trigger creates a new Execution (mapped to job.Job) +// Each trigger creates a new Execution (stored in ExecutionStore) type Robot struct { // From __yao.member MemberID string `json:"member_id"` @@ -1234,8 +1226,7 @@ func (r *Robot) GetExecutions() []*Execution { } // Execution - single execution instance -// Each trigger creates a new Execution, mapped to a job.Job for monitoring -// Relationship: 1 Execution = 1 job.Job +// Each trigger creates a new Execution, stored in ExecutionStore type Execution struct { ID string `json:"id"` // unique execution ID MemberID string `json:"member_id"` // robot member ID (globally unique) @@ -1247,8 +1238,6 @@ type Execution struct { Phase Phase `json:"phase"` Error string `json:"error,omitempty"` - // Job integration (each Execution = 1 job.Job) - JobID string `json:"job_id"` // corresponding job.Job ID // Trigger input (stored for traceability) Input *TriggerInput `json:"input,omitempty"` // original trigger input @@ -2392,7 +2381,6 @@ type ExecutionRecord struct { ExecutionID string `json:"execution_id"` // Unique execution identifier MemberID string `json:"member_id"` // Robot member ID (globally unique) TeamID string `json:"team_id"` // Team ID - JobID string `json:"job_id,omitempty"` // Linked job.Job ID TriggerType TriggerType `json:"trigger_type"` // clock | human | event // Status tracking (synced with runtime Execution) diff --git a/agent/robot/TODO.md b/agent/robot/TODO.md index 84ce4754..6cb4c77f 100644 --- a/agent/robot/TODO.md +++ b/agent/robot/TODO.md @@ -165,7 +165,6 @@ Create empty structs and stub methods that return nil/empty/success: - [x] `dedup/dedup.go` - Dedup struct, stub methods - [x] `store/store.go` - Store struct, stub methods - [x] `pool/pool.go` - Pool struct, stub methods -- [x] `job/job.go` - job helper stubs - [x] `plan/plan.go` - Plan struct, stub methods - [x] `trigger/trigger.go` - trigger dispatcher stub - [x] `executor/executor.go` - Executor struct, stub `Execute()` @@ -278,35 +277,16 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub) - [x] ExecutionController lifecycle tests - [x] Manager integration tests for Intervene/HandleEvent -### ✅ 3.5 Job Integration (COMPLETE) +### ✅ 3.5 Execution Storage (COMPLETE) -- [x] `job/job.go` - create job - - [x] `job_id`: `robot_exec_{execID}` - - [x] `category_name`: `Autonomous Robot` / `自主机器人` (localized) - - [x] Metadata: member_id, team_id, trigger_type, exec_id, display_name - - [x] `Options` struct for extensibility (Priority, MaxRetryCount, DefaultTimeout, Metadata) - - [x] `Create()`, `Get()`, `Update()`, `Complete()`, `Fail()`, `Cancel()` - - [x] Status mapping: ExecPending→queued, ExecRunning→running, etc. +- [x] ExecutionStore - execution record persistence + - [x] Execution data stored in `__yao.agent_execution` table + - [x] All phase outputs (Inspiration, Goals, Tasks, Results, Delivery, Learning) + - [x] Status and phase tracking + - [x] Logging via `kun/log` package - [x] Localization support (en-US, zh-CN) -- [x] `job/execution.go` - execution lifecycle - - [x] `CreateOptions` struct for extensibility - - [x] `CreateExecution()` - create both robot Execution and job.Execution - - [x] `UpdatePhase()` - update phase with progress tracking (10%→25%→40%→60%→80%→95%) - - [x] `UpdateStatus()` - update execution status - - [x] `CompleteExecution()` / `FailExecution()` / `CancelExecution()` - - [x] TriggerType → TriggerCategory mapping (clock→scheduled, human→manual, event→event) - - [x] Duration calculation on completion/failure/cancellation -- [x] `job/log.go` - write phase logs - - [x] `Log()` - base log function with context - - [x] `LogPhaseStart()` / `LogPhaseEnd()` / `LogPhaseError()` - - [x] `LogError()` / `LogInfo()` / `LogDebug()` / `LogWarn()` - - [x] `LogTaskStart()` / `LogTaskEnd()` - - [x] `LogDelivery()` / `LogLearning()` - - [x] Localization support for all log messages -- [x] Test: job creation, execution tracking, log writing - - [x] `job/job_test.go` - 17 test cases - - [x] `job/execution_test.go` - 26 test cases - - [x] `job/log_test.go` - 24 test cases +- [x] Test: execution storage, status tracking + - [x] `store/execution_test.go` - execution store tests - [x] All tests passing with real database ### ✅ 3.6 Executor Architecture (COMPLETE) @@ -875,7 +855,7 @@ Created new `yao/assert` package for universal assertion/validation: - [x] `yao/models/agent/execution.mod.yao` - Execution record model (`agent_execution` table) - [x] id, execution_id (unique) - - [x] member_id (globally unique), team_id, job_id + - [x] member_id (globally unique), team_id - [x] trigger_type (enum: clock, human, event) - [x] **Status tracking** (synced with runtime Execution): - [x] status (enum: pending, running, completed, failed, cancelled) diff --git a/agent/robot/api/README.md b/agent/robot/api/README.md index d49b08c7..bf787b4a 100644 --- a/agent/robot/api/README.md +++ b/agent/robot/api/README.md @@ -19,7 +19,7 @@ result, _ := api.Trigger(ctx, "member_123", &api.TriggerRequest{ }) // Check status -exec, _ := api.GetExecution(ctx, result.JobID) +exec, _ := api.GetExecution(ctx, result.ExecutionID) ``` ## Lifecycle @@ -148,11 +148,11 @@ type TriggerRequest struct { ```go type TriggerResult struct { - Accepted bool // Whether trigger was accepted - Queued bool // Whether queued (vs immediate) - Execution *types.Execution // Execution details - JobID string // Execution ID for tracking - Message string // Status message + Accepted bool // Whether trigger was accepted + Queued bool // Whether queued (vs immediate) + Execution *types.Execution // Execution details + ExecutionID string // Execution ID for tracking + Message string // Status message } ``` diff --git a/agent/robot/api/api_test.go b/agent/robot/api/api_test.go index d27e170a..9130d67e 100644 --- a/agent/robot/api/api_test.go +++ b/agent/robot/api/api_test.go @@ -102,7 +102,7 @@ func TestAPIFullLifecycle(t *testing.T) { require.NoError(t, err) require.NotNil(t, triggerResult) assert.True(t, triggerResult.Accepted) - assert.NotEmpty(t, triggerResult.JobID) + assert.NotEmpty(t, triggerResult.ExecutionID) // 7. Wait for execution to complete time.Sleep(500 * time.Millisecond) @@ -205,6 +205,75 @@ func TestAPIRobotQueryWithData(t *testing.T) { }) } +// TestListRobotsAutonomousModeFilter tests the autonomous_mode filter +func TestListRobotsAutonomousModeFilter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupAPITestRobots(t) + defer cleanupAPITestRobots(t) + + // Setup: Create robots with different autonomous_mode settings + setupAPITestRobotWithMode(t, "robot_api_auto_001", "team_api_mode", true) // autonomous + setupAPITestRobotWithMode(t, "robot_api_auto_002", "team_api_mode", true) // autonomous + setupAPITestRobotWithMode(t, "robot_api_demand_001", "team_api_mode", false) // on-demand + + ctx := types.NewContext(context.Background(), nil) + + t.Run("ListRobots returns all robots when autonomous_mode is nil", func(t *testing.T) { + result, err := api.ListRobots(ctx, &api.ListQuery{ + TeamID: "team_api_mode", + Page: 1, + PageSize: 10, + }) + require.NoError(t, err) + require.NotNil(t, result) + + // Should have all 3 robots + assert.Equal(t, 3, result.Total) + }) + + t.Run("ListRobots filters by autonomous_mode=true", func(t *testing.T) { + autonomousMode := true + result, err := api.ListRobots(ctx, &api.ListQuery{ + TeamID: "team_api_mode", + AutonomousMode: &autonomousMode, + Page: 1, + PageSize: 10, + }) + require.NoError(t, err) + require.NotNil(t, result) + + // Should have only 2 autonomous robots + assert.Equal(t, 2, result.Total) + for _, robot := range result.Data { + assert.True(t, robot.AutonomousMode, "All returned robots should be autonomous") + } + }) + + t.Run("ListRobots filters by autonomous_mode=false", func(t *testing.T) { + autonomousMode := false + result, err := api.ListRobots(ctx, &api.ListQuery{ + TeamID: "team_api_mode", + AutonomousMode: &autonomousMode, + Page: 1, + PageSize: 10, + }) + require.NoError(t, err) + require.NotNil(t, result) + + // Should have only 1 on-demand robot + assert.Equal(t, 1, result.Total) + for _, robot := range result.Data { + assert.False(t, robot.AutonomousMode, "All returned robots should be on-demand") + } + }) +} + // TestAPIExecutionQueryWithData tests execution query APIs with real data func TestAPIExecutionQueryWithData(t *testing.T) { if testing.Short() { @@ -333,7 +402,7 @@ func TestAPITriggerWithData(t *testing.T) { require.NotNil(t, result) assert.True(t, result.Accepted) - assert.NotEmpty(t, result.JobID) + assert.NotEmpty(t, result.ExecutionID) assert.Contains(t, result.Message, "submitted") }) @@ -377,6 +446,44 @@ func TestAPITriggerWithData(t *testing.T) { // ==================== Helper Functions ==================== +// setupAPITestRobotWithMode creates a test robot with specific autonomous_mode setting +func setupAPITestRobotWithMode(t *testing.T, memberID, teamID string, autonomousMode bool) { + m := model.Select("__yao.member") + tableName := m.MetaData.Table.Name + qb := capsule.Query() + + robotConfig := map[string]interface{}{ + "identity": map[string]interface{}{ + "role": "API Test Robot", + "duties": []string{"Testing API functions"}, + }, + "quota": map[string]interface{}{ + "max": 5, + "queue": 20, + "priority": 5, + }, + } + configJSON, _ := json.Marshal(robotConfig) + + err := qb.Table(tableName).Insert([]map[string]interface{}{ + { + "member_id": memberID, + "team_id": teamID, + "member_type": "robot", + "display_name": "API Test Robot " + memberID, + "system_prompt": "You are an API test robot.", + "status": "active", + "role_id": "member", + "autonomous_mode": autonomousMode, + "robot_status": "idle", + "robot_config": string(configJSON), + }, + }) + if err != nil { + t.Fatalf("Failed to insert robot %s: %v", memberID, err) + } +} + // setupAPITestRobot creates a test robot in the database func setupAPITestRobot(t *testing.T, memberID, teamID string) { m := model.Select("__yao.member") @@ -435,7 +542,6 @@ func setupAPITestExecution(t *testing.T, execID, memberID string, triggerType ty ExecutionID: execID, MemberID: memberID, TeamID: "team_api_exec", - JobID: "job_" + execID, TriggerType: triggerType, Status: status, Phase: types.PhaseDelivery, @@ -462,11 +568,17 @@ func cleanupAPITestRobots(t *testing.T) { tableName := m.MetaData.Table.Name qb := capsule.Query() - // Delete all robots with member_id starting with "robot_api_" + // Delete all robots with member_id starting with "robot_api_" or "api_robot_" _, err := qb.Table(tableName).Where("member_id", "like", "robot_api_%").Delete() if err != nil { t.Logf("Warning: cleanup robots error: %v", err) } + + // Also delete "api_robot_" prefixed robots (new tests) + _, err = qb.Table(tableName).Where("member_id", "like", "api_robot_%").Delete() + if err != nil { + t.Logf("Warning: cleanup robots error: %v", err) + } } // cleanupAPITestExecutions removes all API test executions diff --git a/agent/robot/api/e2e_clock_test.go b/agent/robot/api/e2e_clock_test.go index a49ffca7..b3cbea34 100644 --- a/agent/robot/api/e2e_clock_test.go +++ b/agent/robot/api/e2e_clock_test.go @@ -71,9 +71,9 @@ func TestE2EClockTriggerFullFlow(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.Accepted, "Clock trigger should be accepted: %s", result.Message) - assert.NotEmpty(t, result.JobID, "Should return job ID") + assert.NotEmpty(t, result.ExecutionID, "Should return execution ID") - t.Logf("Execution started: JobID=%s", result.JobID) + t.Logf("Execution started: ExecutionID=%s", result.ExecutionID) // Wait for execution to complete (real LLM calls take time) // P0→P4 typically takes 30-60 seconds with real LLM diff --git a/agent/robot/api/e2e_concurrent_test.go b/agent/robot/api/e2e_concurrent_test.go index 0fdaae2f..06b2a48f 100644 --- a/agent/robot/api/e2e_concurrent_test.go +++ b/agent/robot/api/e2e_concurrent_test.go @@ -90,7 +90,7 @@ func TestE2EConcurrentMultipleRobots(t *testing.T) { if result.Accepted { acceptedCount.Add(1) - t.Logf("Robot %s accepted: JobID=%s", id, result.JobID) + t.Logf("Robot %s accepted: ExecutionID=%s", id, result.ExecutionID) } }(i, memberID) } @@ -176,7 +176,7 @@ func TestE2EConcurrentSameRobotMultipleTriggers(t *testing.T) { if result.Accepted { acceptedCount.Add(1) - t.Logf("Trigger %d accepted: JobID=%s", idx, result.JobID) + t.Logf("Trigger %d accepted: ExecutionID=%s", idx, result.ExecutionID) } else { t.Logf("Trigger %d rejected: %s", idx, result.Message) } diff --git a/agent/robot/api/e2e_control_test.go b/agent/robot/api/e2e_control_test.go index 335fa327..25034667 100644 --- a/agent/robot/api/e2e_control_test.go +++ b/agent/robot/api/e2e_control_test.go @@ -61,7 +61,7 @@ func TestE2EControlPauseResume(t *testing.T) { require.NoError(t, err) require.True(t, result.Accepted) - t.Logf("Execution started: JobID=%s", result.JobID) + t.Logf("Execution started: ExecutionID=%s", result.ExecutionID) // Wait for execution to start running var execID string @@ -166,7 +166,7 @@ func TestE2EControlStop(t *testing.T) { require.NoError(t, err) require.True(t, result.Accepted) - t.Logf("Execution started: JobID=%s", result.JobID) + t.Logf("Execution started: ExecutionID=%s", result.ExecutionID) // Wait for execution to start running var execID string diff --git a/agent/robot/api/e2e_event_test.go b/agent/robot/api/e2e_event_test.go index 53f8ab88..640a503a 100644 --- a/agent/robot/api/e2e_event_test.go +++ b/agent/robot/api/e2e_event_test.go @@ -83,7 +83,7 @@ func TestE2EEventTriggerFullFlow(t *testing.T) { require.NotNil(t, result) assert.True(t, result.Accepted, "Event trigger should be accepted") - t.Logf("Event trigger result: Accepted=%v, JobID=%s", result.Accepted, result.JobID) + t.Logf("Event trigger result: Accepted=%v, ExecutionID=%s", result.Accepted, result.ExecutionID) // Wait for execution to complete var exec *types.Execution @@ -256,7 +256,7 @@ func TestE2EEventTriggerVariousEventTypes(t *testing.T) { require.NotNil(t, result) assert.True(t, result.Accepted, "Event should be accepted") - t.Logf("Event triggered: JobID=%s", result.JobID) + t.Logf("Event triggered: ExecutionID=%s", result.ExecutionID) // Wait for execution maxWait := 120 * time.Second diff --git a/agent/robot/api/robot.go b/agent/robot/api/robot.go index 9d096236..dc7b918b 100644 --- a/agent/robot/api/robot.go +++ b/agent/robot/api/robot.go @@ -1,10 +1,14 @@ package api import ( + "context" "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" "github.com/yaoapp/yao/agent/robot/types" ) @@ -14,6 +18,9 @@ import ( // memberModel is the model name for member table const memberModel = "__yao.member" +// robotStore is the shared robot store instance +var robotStore = store.NewRobotStore() + // GetRobot returns a robot by member ID // Returns the robot from cache if available, otherwise loads from database func GetRobot(ctx *types.Context, memberID string) (*types.Robot, error) { @@ -77,15 +84,25 @@ func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) { return nil, err } + // Get permission fields from store (for access control) + record, _ := robotStore.Get(context.Background(), memberID) + state := &RobotState{ MemberID: robot.MemberID, TeamID: robot.TeamID, DisplayName: robot.DisplayName, + Bio: robot.Bio, Status: robot.Status, Running: robot.RunningCount(), MaxRunning: 2, // default } + // Add permission fields if available + if record != nil { + state.YaoCreatedBy = record.YaoCreatedBy + state.YaoTeamID = record.YaoTeamID + } + if robot.Config != nil && robot.Config.Quota != nil { state.MaxRunning = robot.Config.Quota.GetMax() } @@ -121,7 +138,7 @@ func loadRobotFromDB(memberID string) (*types.Robot, error) { records, err := m.Get(model.QueryParam{ Select: []interface{}{ - "id", "member_id", "team_id", "display_name", + "id", "member_id", "team_id", "display_name", "bio", "system_prompt", "robot_status", "autonomous_mode", "robot_config", "robot_email", }, @@ -152,7 +169,6 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) { // Build where conditions wheres := []model.QueryWhere{ {Column: "member_type", Value: "robot"}, - {Column: "autonomous_mode", Value: true}, {Column: "status", Value: "active"}, } @@ -169,6 +185,9 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) { Value: "%" + query.Keywords + "%", }) } + if query.AutonomousMode != nil { + wheres = append(wheres, model.QueryWhere{Column: "autonomous_mode", Value: *query.AutonomousMode}) + } // Build order orders := []model.QueryOrder{} @@ -181,7 +200,7 @@ func listRobotsFromDB(query *ListQuery) (*ListResult, error) { // Execute paginated query result, err := m.Paginate(model.QueryParam{ Select: []interface{}{ - "id", "member_id", "team_id", "display_name", + "id", "member_id", "team_id", "display_name", "bio", "system_prompt", "robot_status", "autonomous_mode", "robot_config", "robot_email", }, @@ -256,3 +275,375 @@ func paginateRobots(robots []*types.Robot, query *ListQuery) *ListResult { PageSize: query.PageSize, } } + +// ==================== Robot CRUD API ==================== +// These functions create, update, and delete robots +// They call store layer for persistence and manage cache +// Request/Response types are defined in types.go + +// 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.TeamID == "" { + return nil, fmt.Errorf("team_id is required") + } + if req.DisplayName == "" { + 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 { + return nil, fmt.Errorf("failed to check existing robot: %w", err) + } + if existing != nil { + return nil, fmt.Errorf("robot with member_id '%s' already exists", req.MemberID) + } + + // Determine autonomous_mode value + autonomousMode := false + if req.AutonomousMode != nil { + autonomousMode = *req.AutonomousMode + } + + // Determine status values + status := "active" + if req.Status != "" { + status = req.Status + } + robotStatus := "idle" + if req.RobotStatus != "" { + robotStatus = req.RobotStatus + } + + // Create store record with all fields + now := time.Now() + record := &store.RobotRecord{ + // Required + MemberID: req.MemberID, + TeamID: req.TeamID, + MemberType: "robot", + Status: status, + RobotStatus: robotStatus, + AutonomousMode: autonomousMode, + + // Profile + DisplayName: req.DisplayName, + Bio: req.Bio, + Avatar: req.Avatar, + + // Identity & Role + SystemPrompt: req.SystemPrompt, + RoleID: req.RoleID, + ManagerID: req.ManagerID, + + // Communication + RobotEmail: req.RobotEmail, + AuthorizedSenders: req.AuthorizedSenders, + EmailFilterRules: req.EmailFilterRules, + + // Capabilities + RobotConfig: req.RobotConfig, + Agents: req.Agents, + MCPServers: req.MCPServers, + LanguageModel: req.LanguageModel, + + // Limits + CostLimit: req.CostLimit, + + // Timestamps + JoinedAt: &now, + } + + // Apply Yao permission fields if provided + if req.AuthScope != nil { + record.YaoCreatedBy = req.AuthScope.CreatedBy + record.YaoTeamID = req.AuthScope.TeamID + record.YaoTenantID = req.AuthScope.TenantID + // Set invited_by from CreatedBy if not explicitly set + if record.InvitedBy == "" && req.AuthScope.CreatedBy != "" { + record.InvitedBy = req.AuthScope.CreatedBy + } + } + + // Save to database + err = robotStore.Save(context.Background(), record) + if err != nil { + return nil, fmt.Errorf("failed to create robot: %w", err) + } + + // 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 { + _ = mgr.Cache().Refresh(ctx, req.MemberID) + } + + // Return the created robot as response + return GetRobotResponse(ctx, req.MemberID) +} + +// UpdateRobot updates an existing robot member +// Calls store.RobotStore.Save() and refreshes cache +func UpdateRobot(ctx *types.Context, memberID string, req *UpdateRobotRequest) (*RobotResponse, error) { + if memberID == "" { + return nil, fmt.Errorf("member_id is required") + } + + // Get existing record + existing, err := robotStore.Get(context.Background(), memberID) + if err != nil { + return nil, fmt.Errorf("failed to get robot: %w", err) + } + if existing == nil { + return nil, types.ErrRobotNotFound + } + + // Apply updates - only non-nil fields are updated + // Profile + if req.DisplayName != nil { + existing.DisplayName = *req.DisplayName + } + if req.Bio != nil { + existing.Bio = *req.Bio + } + if req.Avatar != nil { + existing.Avatar = *req.Avatar + } + + // Identity & Role + if req.SystemPrompt != nil { + existing.SystemPrompt = *req.SystemPrompt + } + if req.RoleID != nil { + existing.RoleID = *req.RoleID + } + if req.ManagerID != nil { + existing.ManagerID = *req.ManagerID + } + + // Status + if req.Status != nil { + existing.Status = *req.Status + } + if req.RobotStatus != nil { + existing.RobotStatus = *req.RobotStatus + } + if req.AutonomousMode != nil { + existing.AutonomousMode = *req.AutonomousMode + } + + // Communication + if req.RobotEmail != nil { + existing.RobotEmail = *req.RobotEmail + } + if req.AuthorizedSenders != nil { + existing.AuthorizedSenders = req.AuthorizedSenders + } + if req.EmailFilterRules != nil { + existing.EmailFilterRules = req.EmailFilterRules + } + + // Capabilities + if req.RobotConfig != nil { + existing.RobotConfig = req.RobotConfig + } + if req.Agents != nil { + existing.Agents = req.Agents + } + if req.MCPServers != nil { + existing.MCPServers = req.MCPServers + } + if req.LanguageModel != nil { + existing.LanguageModel = *req.LanguageModel + } + + // Limits + if req.CostLimit != nil { + existing.CostLimit = *req.CostLimit + } + + // Apply Yao permission fields if provided (update scope) + if req.AuthScope != nil { + existing.YaoUpdatedBy = req.AuthScope.UpdatedBy + // Team and Tenant are typically set on create, not update + // But allow override if explicitly provided + if req.AuthScope.TeamID != "" { + existing.YaoTeamID = req.AuthScope.TeamID + } + if req.AuthScope.TenantID != "" { + existing.YaoTenantID = req.AuthScope.TenantID + } + } + + // Save to database + err = robotStore.Save(context.Background(), existing) + if err != nil { + return nil, fmt.Errorf("failed to update robot: %w", err) + } + + // 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 { + _ = mgr.Cache().Refresh(ctx, memberID) // Ignore error, database is already saved + } + + // Return the updated robot as response + return GetRobotResponse(ctx, memberID) +} + +// RemoveRobot deletes a robot member +// Calls store.RobotStore.Delete() and invalidates cache +func RemoveRobot(ctx *types.Context, memberID string) error { + if memberID == "" { + return fmt.Errorf("member_id is required") + } + + // Check if robot exists + existing, err := robotStore.Get(context.Background(), memberID) + if err != nil { + return fmt.Errorf("failed to get robot: %w", err) + } + if existing == nil { + return types.ErrRobotNotFound + } + + // Check if robot has running executions + mgr, err := getManager() + if err == nil && mgr != nil { + robot := mgr.Cache().Get(memberID) + if robot != nil && robot.RunningCount() > 0 { + return fmt.Errorf("cannot delete robot with running executions") + } + } + + // Delete from database + err = robotStore.Delete(context.Background(), memberID) + if err != nil { + return fmt.Errorf("failed to delete robot: %w", err) + } + + // Invalidate cache if manager is running + if mgr != nil { + mgr.Cache().Remove(memberID) + } + + return nil +} + +// GetRobotResponse retrieves a robot and converts to API response format +func GetRobotResponse(ctx *types.Context, memberID string) (*RobotResponse, error) { + record, err := robotStore.Get(context.Background(), memberID) + if err != nil { + return nil, fmt.Errorf("failed to get robot: %w", err) + } + if record == nil { + return nil, types.ErrRobotNotFound + } + + return recordToResponse(record), nil +} + +// recordToResponse converts a store.RobotRecord to API RobotResponse +func recordToResponse(record *store.RobotRecord) *RobotResponse { + return &RobotResponse{ + ID: record.ID, + MemberID: record.MemberID, + TeamID: record.TeamID, + Status: record.Status, + RobotStatus: record.RobotStatus, + AutonomousMode: record.AutonomousMode, + + DisplayName: record.DisplayName, + Bio: record.Bio, + Avatar: record.Avatar, + + SystemPrompt: record.SystemPrompt, + RoleID: record.RoleID, + ManagerID: record.ManagerID, + + RobotEmail: record.RobotEmail, + AuthorizedSenders: record.AuthorizedSenders, + EmailFilterRules: record.EmailFilterRules, + + RobotConfig: record.RobotConfig, + Agents: record.Agents, + MCPServers: record.MCPServers, + LanguageModel: record.LanguageModel, + + CostLimit: record.CostLimit, + InvitedBy: record.InvitedBy, + JoinedAt: record.JoinedAt, + YaoCreatedBy: record.YaoCreatedBy, + YaoTeamID: record.YaoTeamID, + CreatedAt: record.CreatedAt, + 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 18abd133..5e8188a4 100644 --- a/agent/robot/api/robot_test.go +++ b/agent/robot/api/robot_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/yaoapp/yao/agent/robot/api" "github.com/yaoapp/yao/agent/robot/types" "github.com/yaoapp/yao/agent/testutils" @@ -100,3 +101,376 @@ func TestGetRobotStatusValidation(t *testing.T) { assert.Nil(t, status) }) } + +// ==================== Robot CRUD API Tests ==================== + +// TestCreateRobotValidation tests parameter validation for CreateRobot +func TestCreateRobotValidation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), nil) + + t.Run("auto_generates_member_id_when_empty", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "", + TeamID: "team_001", + DisplayName: "Test Robot Auto ID", + } + result, err := api.CreateRobot(ctx, req) + 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) { + req := &api.CreateRobotRequest{ + MemberID: "robot_test_001", + TeamID: "", + DisplayName: "Test Robot", + } + result, err := api.CreateRobot(ctx, req) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "team_id is required") + }) + + t.Run("returns_error_for_empty_display_name", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "robot_test_001", + TeamID: "team_001", + DisplayName: "", + } + result, err := api.CreateRobot(ctx, req) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "display_name is required") + }) +} + +// TestCreateRobot tests the CreateRobot API function +func TestCreateRobot(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Cleanup before and after + cleanupAPITestRobots(t) + defer cleanupAPITestRobots(t) + + ctx := types.NewContext(context.Background(), nil) + + t.Run("creates_robot_with_required_fields", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "api_robot_create_001", + TeamID: "api_team_001", + DisplayName: "API Test Robot", + } + + result, err := api.CreateRobot(ctx, req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "api_robot_create_001", result.MemberID) + assert.Equal(t, "api_team_001", result.TeamID) + assert.Equal(t, "API Test Robot", result.DisplayName) + assert.Equal(t, "active", result.Status) + assert.Equal(t, "idle", result.RobotStatus) + }) + + t.Run("creates_robot_with_all_fields", func(t *testing.T) { + autonomousMode := true + req := &api.CreateRobotRequest{ + MemberID: "api_robot_create_002", + TeamID: "api_team_002", + DisplayName: "Full Robot", + Bio: "A fully configured robot", + SystemPrompt: "You are a helpful assistant", + Avatar: "https://example.com/avatar.png", + RoleID: "admin", + ManagerID: "user_001", + AutonomousMode: &autonomousMode, + RobotEmail: "fullrobot@test.com", + LanguageModel: "gpt-4", + CostLimit: 100.0, + RobotConfig: map[string]interface{}{ + "clock_mode": "on", + "max_concurrent": 3, + }, + } + + result, err := api.CreateRobot(ctx, req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "api_robot_create_002", result.MemberID) + assert.Equal(t, "Full Robot", result.DisplayName) + assert.Equal(t, "A fully configured robot", result.Bio) + assert.Equal(t, "You are a helpful assistant", result.SystemPrompt) + assert.Equal(t, "admin", result.RoleID) + assert.True(t, result.AutonomousMode) + assert.Equal(t, "fullrobot@test.com", result.RobotEmail) + assert.Equal(t, "gpt-4", result.LanguageModel) + assert.Equal(t, 100.0, result.CostLimit) + }) + + t.Run("creates_robot_with_auth_scope", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "api_robot_create_003", + TeamID: "api_team_003", + DisplayName: "Robot with Auth", + AuthScope: &api.AuthScope{ + CreatedBy: "user_123", + TeamID: "perm_team_001", + TenantID: "tenant_001", + }, + } + + result, err := api.CreateRobot(ctx, req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "api_robot_create_003", result.MemberID) + // InvitedBy should be set from AuthScope.CreatedBy + assert.Equal(t, "user_123", result.InvitedBy) + }) + + t.Run("returns_error_for_duplicate_member_id", func(t *testing.T) { + req := &api.CreateRobotRequest{ + MemberID: "api_robot_create_001", // Already created above + TeamID: "api_team_001", + DisplayName: "Duplicate Robot", + } + + result, err := api.CreateRobot(ctx, req) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "already exists") + }) +} + +// TestUpdateRobot tests the UpdateRobot API function +func TestUpdateRobot(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupAPITestRobots(t) + defer cleanupAPITestRobots(t) + + ctx := types.NewContext(context.Background(), nil) + + // Create a robot to update + createReq := &api.CreateRobotRequest{ + MemberID: "api_robot_update_001", + TeamID: "api_team_update", + DisplayName: "Original Name", + Bio: "Original bio", + } + _, err := api.CreateRobot(ctx, createReq) + require.NoError(t, err) + + t.Run("returns_error_for_empty_member_id", func(t *testing.T) { + req := &api.UpdateRobotRequest{} + result, err := api.UpdateRobot(ctx, "", req) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "member_id is required") + }) + + t.Run("returns_error_for_non_existent_robot", func(t *testing.T) { + newName := "New Name" + req := &api.UpdateRobotRequest{ + DisplayName: &newName, + } + result, err := api.UpdateRobot(ctx, "non_existent_robot", req) + assert.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("updates_display_name", func(t *testing.T) { + newName := "Updated Name" + req := &api.UpdateRobotRequest{ + DisplayName: &newName, + } + + result, err := api.UpdateRobot(ctx, "api_robot_update_001", req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "Updated Name", result.DisplayName) + // Bio should be unchanged + assert.Equal(t, "Original bio", result.Bio) + }) + + t.Run("updates_multiple_fields", func(t *testing.T) { + newBio := "New bio description" + newPrompt := "Updated system prompt" + autonomousMode := true + + req := &api.UpdateRobotRequest{ + Bio: &newBio, + SystemPrompt: &newPrompt, + AutonomousMode: &autonomousMode, + } + + result, err := api.UpdateRobot(ctx, "api_robot_update_001", req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "New bio description", result.Bio) + assert.Equal(t, "Updated system prompt", result.SystemPrompt) + assert.True(t, result.AutonomousMode) + }) + + t.Run("updates_robot_status", func(t *testing.T) { + newStatus := "working" + req := &api.UpdateRobotRequest{ + RobotStatus: &newStatus, + } + + result, err := api.UpdateRobot(ctx, "api_robot_update_001", req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "working", result.RobotStatus) + }) + + t.Run("updates_config", func(t *testing.T) { + newConfig := map[string]interface{}{ + "clock_mode": "off", + "max_concurrent": 5, + } + req := &api.UpdateRobotRequest{ + RobotConfig: newConfig, + } + + result, err := api.UpdateRobot(ctx, "api_robot_update_001", req) + require.NoError(t, err) + require.NotNil(t, result) + + assert.NotNil(t, result.RobotConfig) + }) +} + +// TestRemoveRobot tests the RemoveRobot API function +func TestRemoveRobot(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupAPITestRobots(t) + defer cleanupAPITestRobots(t) + + ctx := types.NewContext(context.Background(), nil) + + t.Run("returns_error_for_empty_member_id", func(t *testing.T) { + err := api.RemoveRobot(ctx, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "member_id is required") + }) + + t.Run("returns_error_for_non_existent_robot", func(t *testing.T) { + err := api.RemoveRobot(ctx, "non_existent_robot") + assert.Error(t, err) + }) + + t.Run("removes_existing_robot", func(t *testing.T) { + // Create a robot + createReq := &api.CreateRobotRequest{ + MemberID: "api_robot_remove_001", + TeamID: "api_team_remove", + DisplayName: "Robot to Remove", + } + _, err := api.CreateRobot(ctx, createReq) + require.NoError(t, err) + + // Verify it exists + robot, err := api.GetRobot(ctx, "api_robot_remove_001") + require.NoError(t, err) + require.NotNil(t, robot) + + // Remove it + err = api.RemoveRobot(ctx, "api_robot_remove_001") + require.NoError(t, err) + + // Verify it's gone + robot, err = api.GetRobot(ctx, "api_robot_remove_001") + assert.Error(t, err) // Should return error for non-existent + }) +} + +// TestGetRobotResponse tests the GetRobotResponse API function +func TestGetRobotResponse(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupAPITestRobots(t) + defer cleanupAPITestRobots(t) + + ctx := types.NewContext(context.Background(), nil) + + // Create a robot + autonomousMode := true + createReq := &api.CreateRobotRequest{ + MemberID: "api_robot_response_001", + TeamID: "api_team_response", + DisplayName: "Response Test Robot", + Bio: "Test bio for response", + SystemPrompt: "Test prompt", + AutonomousMode: &autonomousMode, + RobotEmail: "response@test.com", + CostLimit: 50.0, + } + _, err := api.CreateRobot(ctx, createReq) + require.NoError(t, err) + + t.Run("returns_robot_response_format", func(t *testing.T) { + result, err := api.GetRobotResponse(ctx, "api_robot_response_001") + require.NoError(t, err) + require.NotNil(t, result) + + // Verify all fields are present in response + assert.Equal(t, "api_robot_response_001", result.MemberID) + assert.Equal(t, "api_team_response", result.TeamID) + assert.Equal(t, "Response Test Robot", result.DisplayName) + assert.Equal(t, "Test bio for response", result.Bio) + assert.Equal(t, "Test prompt", result.SystemPrompt) + assert.True(t, result.AutonomousMode) + assert.Equal(t, "response@test.com", result.RobotEmail) + assert.Equal(t, 50.0, result.CostLimit) + assert.Equal(t, "active", result.Status) + assert.Equal(t, "idle", result.RobotStatus) + }) + + t.Run("returns_error_for_non_existent", func(t *testing.T) { + result, err := api.GetRobotResponse(ctx, "non_existent") + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +// Note: cleanupAPITestRobots is defined in api_test.go (shared helper) diff --git a/agent/robot/api/trigger.go b/agent/robot/api/trigger.go index 46db4ac0..f74be8ac 100644 --- a/agent/robot/api/trigger.go +++ b/agent/robot/api/trigger.go @@ -57,9 +57,9 @@ func TriggerManual(ctx *types.Context, memberID string, triggerType types.Trigge } return &TriggerResult{ - Accepted: true, - JobID: execID, - Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType), + Accepted: true, + ExecutionID: execID, + Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType), }, nil } @@ -123,9 +123,9 @@ func triggerHuman(ctx *types.Context, mgr managerInterface, memberID string, req } return &TriggerResult{ - Accepted: true, - JobID: result.ExecutionID, - Message: result.Message, + Accepted: true, + ExecutionID: result.ExecutionID, + Message: result.Message, }, nil } @@ -150,9 +150,9 @@ func triggerEvent(ctx *types.Context, mgr managerInterface, memberID string, req } return &TriggerResult{ - Accepted: true, - JobID: result.ExecutionID, - Message: result.Message, + Accepted: true, + ExecutionID: result.ExecutionID, + Message: result.Message, }, nil } @@ -173,9 +173,9 @@ func triggerManual(ctx *types.Context, mgr managerInterface, memberID string, re } return &TriggerResult{ - Accepted: true, - JobID: execID, - Message: fmt.Sprintf("Trigger (%s) submitted", req.Type), + Accepted: true, + ExecutionID: execID, + Message: fmt.Sprintf("Trigger (%s) submitted", req.Type), }, nil } diff --git a/agent/robot/api/types.go b/agent/robot/api/types.go index 4d60f432..3a990bc6 100644 --- a/agent/robot/api/types.go +++ b/agent/robot/api/types.go @@ -9,13 +9,14 @@ import ( // ListQuery - query options for List() type ListQuery struct { - TeamID string `json:"team_id,omitempty"` - Status types.RobotStatus `json:"status,omitempty"` - Keywords string `json:"keywords,omitempty"` - ClockMode types.ClockMode `json:"clock_mode,omitempty"` - Page int `json:"page,omitempty"` - PageSize int `json:"pagesize,omitempty"` - Order string `json:"order,omitempty"` + TeamID string `json:"team_id,omitempty"` + Status types.RobotStatus `json:"status,omitempty"` + Keywords string `json:"keywords,omitempty"` + ClockMode types.ClockMode `json:"clock_mode,omitempty"` + AutonomousMode *bool `json:"autonomous_mode,omitempty"` // nil=all, true=autonomous only, false=on-demand only + Page int `json:"page,omitempty"` + PageSize int `json:"pagesize,omitempty"` + Order string `json:"order,omitempty"` } // ListResult - result of List() @@ -28,15 +29,18 @@ type ListResult struct { // RobotState - runtime state from Status() type RobotState struct { - MemberID string `json:"member_id"` - TeamID string `json:"team_id"` - DisplayName string `json:"display_name"` - Status types.RobotStatus `json:"status"` - Running int `json:"running"` - MaxRunning int `json:"max_running"` - LastRun *time.Time `json:"last_run,omitempty"` - NextRun *time.Time `json:"next_run,omitempty"` - RunningIDs []string `json:"running_ids,omitempty"` + MemberID string `json:"member_id"` + TeamID string `json:"team_id"` + DisplayName string `json:"display_name"` + Bio string `json:"bio,omitempty"` + Status types.RobotStatus `json:"status"` + Running int `json:"running"` + MaxRunning int `json:"max_running"` + LastRun *time.Time `json:"last_run,omitempty"` + NextRun *time.Time `json:"next_run,omitempty"` + RunningIDs []string `json:"running_ids,omitempty"` + YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id for permission check + YaoTeamID string `json:"__yao_team_id,omitempty"` // Team ID for permission check } // ==================== Trigger Types ==================== @@ -78,11 +82,11 @@ const ( // TriggerResult - result of Trigger() type TriggerResult struct { - Accepted bool `json:"accepted"` - Queued bool `json:"queued"` - Execution *types.Execution `json:"execution,omitempty"` - JobID string `json:"job_id,omitempty"` - Message string `json:"message,omitempty"` + Accepted bool `json:"accepted"` + Queued bool `json:"queued"` + Execution *types.Execution `json:"execution,omitempty"` + ExecutionID string `json:"execution_id,omitempty"` // Execution ID + Message string `json:"message,omitempty"` } // ==================== Execution Types ==================== @@ -103,6 +107,136 @@ type ExecutionResult struct { PageSize int `json:"pagesize"` } +// ==================== CRUD Types ==================== + +// AuthScope contains Yao permission fields for data scoping +// These fields are used by Yao's permission system (when model has permission: true) +type AuthScope struct { + CreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id + UpdatedBy string `json:"__yao_updated_by,omitempty"` // Updater user_id + TeamID string `json:"__yao_team_id,omitempty"` // Permission team scope + TenantID string `json:"__yao_tenant_id,omitempty"` // Permission tenant scope +} + +// CreateRobotRequest - request for CreateRobot() +type CreateRobotRequest struct { + // 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 + Bio string `json:"bio,omitempty"` // Robot description + Avatar string `json:"avatar,omitempty"` // Avatar URL + + // Identity & Role + SystemPrompt string `json:"system_prompt,omitempty"` // System prompt + RoleID string `json:"role_id,omitempty"` // Role within team + ManagerID string `json:"manager_id,omitempty"` // Direct manager user_id + + // Status + Status string `json:"status,omitempty"` // Member status: active | inactive | pending | suspended + RobotStatus string `json:"robot_status,omitempty"` // Robot status: idle | working | paused | error | maintenance + AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Whether autonomous mode is enabled + + // Communication + RobotEmail string `json:"robot_email,omitempty"` // Robot email address + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array) + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array) + + // Capabilities + RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON + Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array) + MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array) + LanguageModel string `json:"language_model,omitempty"` // Language model name + + // Limits + CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD + + // Auth scope (optional, used by OpenAPI layer via WithCreateScope) + AuthScope *AuthScope `json:"auth_scope,omitempty"` +} + +// UpdateRobotRequest - request for UpdateRobot() +type UpdateRobotRequest struct { + // Profile + DisplayName *string `json:"display_name,omitempty"` // Display name + Bio *string `json:"bio,omitempty"` // Robot description + Avatar *string `json:"avatar,omitempty"` // Avatar URL + + // Identity & Role + SystemPrompt *string `json:"system_prompt,omitempty"` // System prompt + RoleID *string `json:"role_id,omitempty"` // Role within team + ManagerID *string `json:"manager_id,omitempty"` // Direct manager user_id + + // Status + Status *string `json:"status,omitempty"` // Member status + RobotStatus *string `json:"robot_status,omitempty"` // Robot status + AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Autonomous mode + + // Communication + RobotEmail *string `json:"robot_email,omitempty"` // Robot email address + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules + + // Capabilities + RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON + Agents interface{} `json:"agents,omitempty"` // Accessible agents + MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers + LanguageModel *string `json:"language_model,omitempty"` // Language model name + + // Limits + CostLimit *float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD + + // Auth scope (optional, used by OpenAPI layer via WithUpdateScope) + AuthScope *AuthScope `json:"auth_scope,omitempty"` +} + +// RobotResponse - response containing robot details for API +type RobotResponse struct { + // Basic + ID int64 `json:"id,omitempty"` + MemberID string `json:"member_id"` + TeamID string `json:"team_id"` + Status string `json:"status"` + RobotStatus string `json:"robot_status"` + AutonomousMode bool `json:"autonomous_mode"` + + // Profile + DisplayName string `json:"display_name"` + Bio string `json:"bio,omitempty"` + Avatar string `json:"avatar,omitempty"` + + // Identity & Role + SystemPrompt string `json:"system_prompt,omitempty"` + RoleID string `json:"role_id,omitempty"` + ManagerID string `json:"manager_id,omitempty"` + + // Communication + RobotEmail string `json:"robot_email,omitempty"` + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` + + // Capabilities + RobotConfig interface{} `json:"robot_config,omitempty"` + Agents interface{} `json:"agents,omitempty"` + MCPServers interface{} `json:"mcp_servers,omitempty"` + LanguageModel string `json:"language_model,omitempty"` + + // Limits + CostLimit float64 `json:"cost_limit,omitempty"` + + // Ownership & Audit + InvitedBy string `json:"invited_by,omitempty"` + JoinedAt *time.Time `json:"joined_at,omitempty"` + YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id for permission check + YaoTeamID string `json:"__yao_team_id,omitempty"` // Team ID for permission check + + // Timestamps + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + // ==================== Helper Functions ==================== // applyDefaults applies default values to ListQuery diff --git a/agent/robot/cache/load.go b/agent/robot/cache/load.go index 60c832b6..25189ec7 100644 --- a/agent/robot/cache/load.go +++ b/agent/robot/cache/load.go @@ -18,10 +18,12 @@ var memberFields = []interface{}{ "member_id", "team_id", "display_name", + "bio", "system_prompt", "robot_status", "autonomous_mode", "robot_config", + "robot_email", } // SetMemberModel sets the member model name diff --git a/agent/robot/executor/executor_test.go b/agent/robot/executor/executor_test.go index cfb69eb3..f9af13b0 100644 --- a/agent/robot/executor/executor_test.go +++ b/agent/robot/executor/executor_test.go @@ -9,8 +9,7 @@ import ( ) // Smoke tests to verify basic flow works -// These tests use SkipJobIntegration=true to avoid DB dependencies -// Real integration tests are in manager_test.go and job_test.go +// Real integration tests are in manager_test.go func TestExecutorSmoke(t *testing.T) { exec := NewDryRunWithDelay(0) diff --git a/agent/robot/executor/standard/executor.go b/agent/robot/executor/standard/executor.go index c94430a5..dd2cb1d0 100644 --- a/agent/robot/executor/standard/executor.go +++ b/agent/robot/executor/standard/executor.go @@ -5,18 +5,18 @@ import ( "sync/atomic" "time" + "github.com/yaoapp/kun/log" "github.com/yaoapp/yao/agent/robot/executor/types" - "github.com/yaoapp/yao/agent/robot/job" "github.com/yaoapp/yao/agent/robot/store" robottypes "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/agent/robot/utils" ) // Executor implements the standard executor with real Agent calls // This is the production executor that: -// - Creates Job records for tracking // - Persists execution history to database // - Calls real Agents via Assistant.Stream() -// - Logs phase transitions and errors +// - Logs phase transitions and errors using kun/log type Executor struct { config types.Config store *store.ExecutionStore @@ -47,36 +47,22 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri return nil, fmt.Errorf("robot cannot be nil") } - var exec *robottypes.Execution - var err error - // Determine starting phase based on trigger type startPhaseIndex := 0 if trigger == robottypes.TriggerHuman || trigger == robottypes.TriggerEvent { startPhaseIndex = 1 // Skip P0 (Inspiration) } - // Create execution with Job integration - if !e.config.SkipJobIntegration { - exec, err = job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: trigger, - Input: types.BuildTriggerInput(trigger, data), - }) - if err != nil { - return nil, fmt.Errorf("failed to create execution: %w", err) - } - } else { - exec = &robottypes.Execution{ - ID: fmt.Sprintf("exec_%d", time.Now().UnixNano()), - MemberID: robot.MemberID, - TeamID: robot.TeamID, - TriggerType: trigger, - StartTime: time.Now(), - Status: robottypes.ExecPending, - Phase: robottypes.AllPhases[startPhaseIndex], - Input: types.BuildTriggerInput(trigger, data), - } + // Create execution (Job system removed, using ExecutionStore only) + exec := &robottypes.Execution{ + ID: utils.NewID(), + MemberID: robot.MemberID, + TeamID: robot.TeamID, + TriggerType: trigger, + StartTime: time.Now(), + Status: robottypes.ExecPending, + Phase: robottypes.AllPhases[startPhaseIndex], + Input: types.BuildTriggerInput(trigger, data), } // Set robot reference for phase methods @@ -88,17 +74,20 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri record := store.FromExecution(exec) if err := e.store.Save(ctx.Context, record); err != nil { // Log warning but don't fail execution - if !e.config.SkipJobIntegration { - _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist execution record: %v", err)) - } + log.With(log.F{ + "execution_id": exec.ID, + "member_id": exec.MemberID, + "error": err, + }).Warn("Failed to persist execution record: %v", err) } } // Acquire execution slot if !robot.TryAcquireSlot(exec) { - if !e.config.SkipJobIntegration && exec.JobID != "" { - _ = job.FailExecution(ctx, exec, robottypes.ErrQuotaExceeded) - } + log.With(log.F{ + "execution_id": exec.ID, + "member_id": exec.MemberID, + }).Warn("Execution quota exceeded") return nil, robottypes.ErrQuotaExceeded } defer robot.RemoveExecution(exec.ID) @@ -118,17 +107,19 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri // Update status to running exec.Status = robottypes.ExecRunning - if !e.config.SkipJobIntegration { - if err := job.UpdateStatus(ctx, exec, robottypes.ExecRunning); err != nil { - _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update status to running: %v", err)) - } - } + log.With(log.F{ + "execution_id": exec.ID, + "member_id": exec.MemberID, + "trigger_type": string(exec.TriggerType), + }).Info("Execution started") + // Persist running status if !e.config.SkipPersistence && e.store != nil { if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecRunning, ""); err != nil { - if !e.config.SkipJobIntegration { - _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist running status: %v", err)) - } + log.With(log.F{ + "execution_id": exec.ID, + "error": err, + }).Warn("Failed to persist running status: %v", err) } } @@ -136,9 +127,10 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" { exec.Status = robottypes.ExecFailed exec.Error = "simulated failure" - if !e.config.SkipJobIntegration { - _ = job.FailExecution(ctx, exec, fmt.Errorf("simulated failure")) - } + log.With(log.F{ + "execution_id": exec.ID, + "member_id": exec.MemberID, + }).Warn("Simulated failure triggered") // Persist failed status if !e.config.SkipPersistence && e.store != nil { _ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, "simulated failure") @@ -152,9 +144,12 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri if err := e.runPhase(ctx, exec, phase, data); err != nil { exec.Status = robottypes.ExecFailed exec.Error = err.Error() - if !e.config.SkipJobIntegration { - _ = job.FailExecution(ctx, exec, err) - } + log.With(log.F{ + "execution_id": exec.ID, + "member_id": exec.MemberID, + "phase": string(phase), + "error": err.Error(), + }).Error("Phase execution failed: %v", err) // Persist failed status if !e.config.SkipPersistence && e.store != nil { _ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, err.Error()) @@ -168,17 +163,20 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri now := time.Now() exec.EndTime = &now - if !e.config.SkipJobIntegration { - if err := job.CompleteExecution(ctx, exec); err != nil { - _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to mark execution as completed: %v", err)) - } - } + duration := now.Sub(exec.StartTime) + log.With(log.F{ + "execution_id": exec.ID, + "member_id": exec.MemberID, + "duration_ms": duration.Milliseconds(), + }).Info("Execution completed successfully") + // Persist completed status if !e.config.SkipPersistence && e.store != nil { if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecCompleted, ""); err != nil { - if !e.config.SkipJobIntegration { - _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist completed status: %v", err)) - } + log.With(log.F{ + "execution_id": exec.ID, + "error": err, + }).Warn("Failed to persist completed status: %v", err) } } @@ -189,11 +187,11 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, phase robottypes.Phase, data interface{}) error { exec.Phase = phase - if !e.config.SkipJobIntegration { - if err := job.UpdatePhase(ctx, exec, phase); err != nil { - _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update phase to %s: %v", phase, err)) - } - } + log.With(log.F{ + "execution_id": exec.ID, + "member_id": exec.MemberID, + "phase": string(phase), + }).Info("Phase started: %s", phase) if e.config.OnPhaseStart != nil { e.config.OnPhaseStart(phase) @@ -219,9 +217,12 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, } if err != nil { - if !e.config.SkipJobIntegration { - _ = job.LogPhaseError(ctx, exec, phase, err) - } + log.With(log.F{ + "execution_id": exec.ID, + "member_id": exec.MemberID, + "phase": string(phase), + "error": err.Error(), + }).Error("Phase failed: %s - %v", phase, err) return err } @@ -231,9 +232,11 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, if phaseData != nil { if err := e.store.UpdatePhase(ctx.Context, exec.ID, phase, phaseData); err != nil { // Log warning but don't fail execution - if !e.config.SkipJobIntegration { - _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist phase %s data: %v", phase, err)) - } + log.With(log.F{ + "execution_id": exec.ID, + "phase": string(phase), + "error": err, + }).Warn("Failed to persist phase %s data: %v", phase, err) } } } @@ -242,10 +245,13 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, e.config.OnPhaseEnd(phase) } - if !e.config.SkipJobIntegration { - phaseDuration := time.Since(phaseStart).Milliseconds() - _ = job.LogPhaseEnd(ctx, exec, phase, phaseDuration) - } + phaseDuration := time.Since(phaseStart).Milliseconds() + log.With(log.F{ + "execution_id": exec.ID, + "member_id": exec.MemberID, + "phase": string(phase), + "duration_ms": phaseDuration, + }).Info("Phase completed: %s (took %dms)", phase, phaseDuration) return nil } diff --git a/agent/robot/executor/standard/executor_test.go b/agent/robot/executor/standard/executor_test.go index 89ede60d..4d2bcc22 100644 --- a/agent/robot/executor/standard/executor_test.go +++ b/agent/robot/executor/standard/executor_test.go @@ -34,10 +34,9 @@ func TestExecutorPersistence(t *testing.T) { robot := createPersistenceTestRobot("member_persist_001", "team_persist_001") - // Create executor with persistence enabled but skip job integration + // Create executor with persistence enabled e := standard.NewWithConfig(types.Config{ - SkipJobIntegration: true, - SkipPersistence: false, + SkipPersistence: false, }) // Execute with simulated failure to ensure we get a result @@ -71,8 +70,7 @@ func TestExecutorPersistence(t *testing.T) { robot := createPersistenceTestRobot("member_persist_002", "team_persist_002") e := standard.NewWithConfig(types.Config{ - SkipJobIntegration: true, - SkipPersistence: false, + SkipPersistence: false, }) // Execute with simulated failure @@ -104,8 +102,7 @@ func TestExecutorPersistence(t *testing.T) { // Create executor with persistence disabled e := standard.NewWithConfig(types.Config{ - SkipJobIntegration: true, - SkipPersistence: true, + SkipPersistence: true, }) exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure") diff --git a/agent/robot/executor/types/types.go b/agent/robot/executor/types/types.go index c2c3629c..c1a52380 100644 --- a/agent/robot/executor/types/types.go +++ b/agent/robot/executor/types/types.go @@ -50,9 +50,6 @@ type PhaseExecutor interface { // Config holds common executor configuration type Config struct { - // SkipJobIntegration skips job system integration (for testing) - SkipJobIntegration bool - // SkipPersistence skips execution record persistence (for testing) SkipPersistence bool diff --git a/agent/robot/job/execution.go b/agent/robot/job/execution.go deleted file mode 100644 index b65eb065..00000000 --- a/agent/robot/job/execution.go +++ /dev/null @@ -1,433 +0,0 @@ -package job - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/yaoapp/gou/model" - yaojob "github.com/yaoapp/yao/job" - - "github.com/yaoapp/yao/agent/robot/types" -) - -// CreateOptions holds options for creating a new execution -type CreateOptions struct { - Robot *types.Robot // Required: the robot to execute - TriggerType types.TriggerType // Required: clock | human | event - Input *types.TriggerInput // Optional: trigger input data - - // Optional fields for future extension - Priority int // Execution priority (higher = more important) - TimeoutSeconds *int // Execution timeout - ParentExecutionID string // Parent execution ID for sub-tasks - ScheduledAt *time.Time // Scheduled execution time (for delayed execution) - Metadata map[string]interface{} // Custom metadata -} - -// Validate validates the CreateOptions -func (o *CreateOptions) Validate() error { - if o.Robot == nil { - return fmt.Errorf("robot is required") - } - if o.TriggerType == "" { - return fmt.Errorf("trigger type is required") - } - return nil -} - -// CreateExecution creates a new execution record in the job system -// This creates both the robot Execution and the corresponding job.Execution -func CreateExecution(ctx *types.Context, opts *CreateOptions) (*types.Execution, error) { - if opts == nil { - return nil, fmt.Errorf("options is nil") - } - if err := opts.Validate(); err != nil { - return nil, err - } - - robot := opts.Robot - triggerType := opts.TriggerType - - // Create job for this execution (returns jobID and execID) - jobID, execID, err := Create(ctx, &Options{ - Robot: robot, - TriggerType: triggerType, - Priority: opts.Priority, - Metadata: opts.Metadata, - }) - if err != nil { - return nil, fmt.Errorf("failed to create job: %w", err) - } - - // Determine starting phase based on trigger type - // Clock trigger starts from P0 (Inspiration) - // Human/Event triggers skip P0 and start from P1 (Goals) - startPhase := types.PhaseInspiration - if triggerType == types.TriggerHuman || triggerType == types.TriggerEvent { - startPhase = types.PhaseGoals - } - - // Create robot execution - exec := &types.Execution{ - ID: execID, - MemberID: robot.MemberID, - TeamID: robot.TeamID, - TriggerType: triggerType, - StartTime: time.Now(), - Status: types.ExecPending, - Phase: startPhase, - Input: opts.Input, - JobID: jobID, - } - - // Build trigger context for job execution - triggerContext, _ := json.Marshal(map[string]interface{}{ - "trigger_type": string(triggerType), - "member_id": robot.MemberID, - "team_id": robot.TeamID, - }) - triggerContextRaw := json.RawMessage(triggerContext) - - // Map trigger type to trigger category - // TriggerCategory ENUM: manual, scheduled, event, api, system, dependency - triggerCategory := mapTriggerTypeToCategory(triggerType) - triggerSource := string(triggerType) // Store original trigger type as source - - // Create job execution record - jobExec := &yaojob.Execution{ - ExecutionID: execID, - JobID: jobID, - Status: "queued", - TriggerCategory: triggerCategory, - TriggerSource: &triggerSource, - TriggerContext: &triggerContextRaw, - Progress: 0, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - // Apply optional fields - if opts.TimeoutSeconds != nil { - jobExec.TimeoutSeconds = opts.TimeoutSeconds - } - if opts.ParentExecutionID != "" { - jobExec.ParentExecutionID = &opts.ParentExecutionID - } - if opts.ScheduledAt != nil { - jobExec.ScheduledAt = opts.ScheduledAt - } - if opts.Priority > 0 { - jobExec.ExecutionOptions = &yaojob.ExecutionOptions{ - Priority: opts.Priority, - } - } - - if err := yaojob.SaveExecution(jobExec); err != nil { - return nil, fmt.Errorf("failed to save job execution: %w", err) - } - - // Note: Job status is automatically updated by yaojob.SaveExecution -> updateJobProgress - // No need to manually set job status here - - return exec, nil -} - -// UpdatePhase updates the execution phase in the job system -func UpdatePhase(ctx *types.Context, exec *types.Execution, phase types.Phase) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - exec.Phase = phase - - // Update job - if err := Update(ctx, exec); err != nil { - return fmt.Errorf("failed to update job: %w", err) - } - - // Update job execution progress - progress := phaseToProgress(phase) - if err := updateExecutionProgress(exec.ID, progress, string(phase)); err != nil { - return fmt.Errorf("failed to update execution progress: %w", err) - } - - // Log phase transition (ignore error, non-critical) - _ = LogPhaseStart(ctx, exec, phase) - - return nil -} - -// UpdateStatus updates the execution status in the job system -func UpdateStatus(ctx *types.Context, exec *types.Execution, status types.ExecStatus) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - exec.Status = status - - // Update job - if err := Update(ctx, exec); err != nil { - return fmt.Errorf("failed to update job: %w", err) - } - - // Update job execution status - if err := updateExecutionStatus(exec.ID, status); err != nil { - return fmt.Errorf("failed to update execution status: %w", err) - } - - return nil -} - -// CompleteExecution marks execution as completed -func CompleteExecution(ctx *types.Context, exec *types.Execution) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - now := time.Now() - exec.EndTime = &now - exec.Status = types.ExecCompleted - - // Complete the job - if err := Complete(ctx, exec); err != nil { - return fmt.Errorf("failed to complete job: %w", err) - } - - // Update job execution - if err := completeJobExecution(exec.ID, exec.StartTime); err != nil { - return fmt.Errorf("failed to complete job execution: %w", err) - } - - // Log completion (ignore error, non-critical) - locale := getLocale(ctx) - var msg string - if isChineseLocale(locale) { - msg = "执行完成" - } else { - msg = "Execution completed successfully" - } - _ = Log(ctx, exec, "info", msg, map[string]interface{}{ - "duration_ms": now.Sub(exec.StartTime).Milliseconds(), - }) - - return nil -} - -// FailExecution marks execution as failed -func FailExecution(ctx *types.Context, exec *types.Execution, execErr error) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - now := time.Now() - exec.EndTime = &now - exec.Status = types.ExecFailed - if execErr != nil { - exec.Error = execErr.Error() - } - - // Fail the job - if err := Fail(ctx, exec, execErr); err != nil { - return fmt.Errorf("failed to fail job: %w", err) - } - - // Update job execution - if err := failJobExecution(exec.ID, execErr, exec.StartTime); err != nil { - return fmt.Errorf("failed to fail job execution: %w", err) - } - - // Log failure (ignore error, non-critical) - _ = LogError(ctx, exec, execErr) - - return nil -} - -// CancelExecution marks execution as cancelled -func CancelExecution(ctx *types.Context, exec *types.Execution) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - now := time.Now() - exec.EndTime = &now - exec.Status = types.ExecCancelled - - // Cancel the job - if err := Cancel(ctx, exec); err != nil { - return fmt.Errorf("failed to cancel job: %w", err) - } - - // Update job execution - if err := cancelJobExecution(exec.ID, exec.StartTime); err != nil { - return fmt.Errorf("failed to cancel job execution: %w", err) - } - - // Log cancellation (ignore error, non-critical) - locale := getLocale(ctx) - var msg string - if isChineseLocale(locale) { - msg = "执行已取消" - } else { - msg = "Execution cancelled" - } - _ = Log(ctx, exec, "info", msg, nil) - - return nil -} - -// GetExecution retrieves a job execution by ID -func GetExecution(executionID string) (*yaojob.Execution, error) { - if executionID == "" { - return nil, fmt.Errorf("execution ID is empty") - } - return yaojob.GetExecution(executionID, model.QueryParam{}) -} - -// ListExecutions lists executions for a job -func ListExecutions(jobID string) ([]*yaojob.Execution, error) { - if jobID == "" { - return nil, fmt.Errorf("job ID is empty") - } - return yaojob.GetExecutions(jobID) -} - -// phaseToProgress maps phase to progress percentage -func phaseToProgress(phase types.Phase) int { - switch phase { - case types.PhaseInspiration: - return 10 - case types.PhaseGoals: - return 25 - case types.PhaseTasks: - return 40 - case types.PhaseRun: - return 60 - case types.PhaseDelivery: - return 80 - case types.PhaseLearning: - return 95 - default: - return 0 - } -} - -// updateExecutionProgress updates the job execution progress -// Note: step parameter is kept for future use if yaojob.Execution adds Step field -func updateExecutionProgress(executionID string, progress int, _ string) error { - exec, err := yaojob.GetExecution(executionID, model.QueryParam{}) - if err != nil { - return err - } - - exec.Progress = progress - exec.UpdatedAt = time.Now() - - return yaojob.SaveExecution(exec) -} - -// updateExecutionStatus updates the job execution status -func updateExecutionStatus(executionID string, status types.ExecStatus) error { - exec, err := yaojob.GetExecution(executionID, model.QueryParam{}) - if err != nil { - return err - } - - exec.Status = mapStatusToJobStatus(status) - exec.UpdatedAt = time.Now() - - if status == types.ExecRunning && exec.StartedAt == nil { - now := time.Now() - exec.StartedAt = &now - } - - return yaojob.SaveExecution(exec) -} - -// completeJobExecution marks job execution as completed -func completeJobExecution(executionID string, startTime time.Time) error { - exec, err := yaojob.GetExecution(executionID, model.QueryParam{}) - if err != nil { - return err - } - - now := time.Now() - exec.Status = "completed" - exec.Progress = 100 - exec.EndedAt = &now - exec.UpdatedAt = now - - // Calculate duration (handle zero startTime) - if !startTime.IsZero() { - duration := int(now.Sub(startTime).Milliseconds()) - exec.Duration = &duration - } - - return yaojob.SaveExecution(exec) -} - -// failJobExecution marks job execution as failed -func failJobExecution(executionID string, execErr error, startTime time.Time) error { - exec, err := yaojob.GetExecution(executionID, model.QueryParam{}) - if err != nil { - return err - } - - now := time.Now() - exec.Status = "failed" - exec.EndedAt = &now - exec.UpdatedAt = now - - // Calculate duration (handle zero startTime) - if !startTime.IsZero() { - duration := int(now.Sub(startTime).Milliseconds()) - exec.Duration = &duration - } - - // Store error info - if execErr != nil { - errorInfo, _ := json.Marshal(map[string]string{ - "message": execErr.Error(), - }) - errorInfoRaw := json.RawMessage(errorInfo) - exec.ErrorInfo = &errorInfoRaw - } - - return yaojob.SaveExecution(exec) -} - -// cancelJobExecution marks job execution as cancelled -func cancelJobExecution(executionID string, startTime time.Time) error { - exec, err := yaojob.GetExecution(executionID, model.QueryParam{}) - if err != nil { - return err - } - - now := time.Now() - exec.Status = "cancelled" - exec.EndedAt = &now - exec.UpdatedAt = now - - // Calculate duration (handle zero startTime) - if !startTime.IsZero() { - duration := int(now.Sub(startTime).Milliseconds()) - exec.Duration = &duration - } - - return yaojob.SaveExecution(exec) -} - -// mapTriggerTypeToCategory maps robot TriggerType to job execution TriggerCategory -// TriggerCategory ENUM values: manual, scheduled, event, api, system, dependency -func mapTriggerTypeToCategory(triggerType types.TriggerType) string { - switch triggerType { - case types.TriggerClock: - return "scheduled" - case types.TriggerHuman: - return "manual" - case types.TriggerEvent: - return "event" - default: - return "system" - } -} diff --git a/agent/robot/job/execution_test.go b/agent/robot/job/execution_test.go deleted file mode 100644 index e63f082d..00000000 --- a/agent/robot/job/execution_test.go +++ /dev/null @@ -1,559 +0,0 @@ -package job_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/yaoapp/yao/agent/robot/job" - "github.com/yaoapp/yao/agent/robot/types" - "github.com/yaoapp/yao/agent/testutils" -) - -// TestCreateExecution tests creating a new execution -func TestCreateExecution(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("create execution with clock trigger", func(t *testing.T) { - robot := createTestRobot("test_exec_create_001") - - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - - require.NoError(t, err) - assert.NotNil(t, exec) - assert.NotEmpty(t, exec.ID) - assert.NotEmpty(t, exec.JobID) - assert.Equal(t, robot.MemberID, exec.MemberID) - assert.Equal(t, robot.TeamID, exec.TeamID) - assert.Equal(t, types.TriggerClock, exec.TriggerType) - assert.Equal(t, types.ExecPending, exec.Status) - // Clock trigger starts from P0 (Inspiration) - assert.Equal(t, types.PhaseInspiration, exec.Phase) - assert.False(t, exec.StartTime.IsZero()) - }) - - t.Run("create execution with human trigger starts from P1", func(t *testing.T) { - robot := createTestRobot("test_exec_create_002") - - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerHuman, - }) - - require.NoError(t, err) - assert.NotNil(t, exec) - // Human trigger skips P0, starts from P1 (Goals) - assert.Equal(t, types.PhaseGoals, exec.Phase) - }) - - t.Run("create execution with event trigger starts from P1", func(t *testing.T) { - robot := createTestRobot("test_exec_create_003") - - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerEvent, - }) - - require.NoError(t, err) - assert.NotNil(t, exec) - // Event trigger skips P0, starts from P1 (Goals) - assert.Equal(t, types.PhaseGoals, exec.Phase) - }) - - t.Run("create execution with input", func(t *testing.T) { - robot := createTestRobot("test_exec_create_004") - input := &types.TriggerInput{ - Action: types.ActionTaskAdd, - UserID: "test_user_001", - } - - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerHuman, - Input: input, - }) - - require.NoError(t, err) - assert.NotNil(t, exec) - assert.NotNil(t, exec.Input) - assert.Equal(t, types.ActionTaskAdd, exec.Input.Action) - }) - - t.Run("create execution with optional fields", func(t *testing.T) { - robot := createTestRobot("test_exec_create_005") - timeout := 300 - scheduledAt := time.Now().Add(1 * time.Hour) - - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - Priority: 5, - TimeoutSeconds: &timeout, - ParentExecutionID: "parent_exec_001", - ScheduledAt: &scheduledAt, - Metadata: map[string]interface{}{ - "source": "test", - }, - }) - - require.NoError(t, err) - assert.NotNil(t, exec) - }) - - t.Run("create execution with nil robot returns error", func(t *testing.T) { - _, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: nil, - TriggerType: types.TriggerClock, - }) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "robot is required") - }) - - t.Run("create execution with empty trigger type returns error", func(t *testing.T) { - robot := createTestRobot("test_exec_create_006") - - _, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: "", - }) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "trigger type is required") - }) - - t.Run("create execution with nil options returns error", func(t *testing.T) { - _, err := job.CreateExecution(ctx, nil) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "options is nil") - }) -} - -// TestUpdatePhase tests updating execution phase -func TestUpdatePhase(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("update phase successfully", func(t *testing.T) { - robot := createTestRobot("test_phase_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - // Update to Goals phase - err = job.UpdatePhase(ctx, exec, types.PhaseGoals) - require.NoError(t, err) - assert.Equal(t, types.PhaseGoals, exec.Phase) - - // Verify job was updated - j, err := job.Get(exec.JobID) - require.NoError(t, err) - assert.Equal(t, string(types.PhaseGoals), j.Config["current_phase"]) - }) - - t.Run("update through all phases", func(t *testing.T) { - robot := createTestRobot("test_phase_002") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - phases := []types.Phase{ - types.PhaseGoals, - types.PhaseTasks, - types.PhaseRun, - types.PhaseDelivery, - types.PhaseLearning, - } - - for _, phase := range phases { - err = job.UpdatePhase(ctx, exec, phase) - require.NoError(t, err) - assert.Equal(t, phase, exec.Phase) - } - }) - - t.Run("update phase with nil execution returns error", func(t *testing.T) { - err := job.UpdatePhase(ctx, nil, types.PhaseGoals) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid execution") - }) - - t.Run("update phase with empty execution ID returns error", func(t *testing.T) { - exec := &types.Execution{ - ID: "", - JobID: "some_job_id", - } - err := job.UpdatePhase(ctx, exec, types.PhaseGoals) - assert.Error(t, err) - }) - - t.Run("update phase with empty job ID returns error", func(t *testing.T) { - exec := &types.Execution{ - ID: "some_exec_id", - JobID: "", - } - err := job.UpdatePhase(ctx, exec, types.PhaseGoals) - assert.Error(t, err) - }) -} - -// TestUpdateStatus tests updating execution status -func TestUpdateStatus(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("update status to running", func(t *testing.T) { - robot := createTestRobot("test_status_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.UpdateStatus(ctx, exec, types.ExecRunning) - require.NoError(t, err) - assert.Equal(t, types.ExecRunning, exec.Status) - - // Verify job was updated - j, err := job.Get(exec.JobID) - require.NoError(t, err) - assert.Equal(t, "running", j.Status) - }) - - t.Run("update status with nil execution returns error", func(t *testing.T) { - err := job.UpdateStatus(ctx, nil, types.ExecRunning) - assert.Error(t, err) - }) -} - -// TestCompleteExecution tests completing an execution -func TestCompleteExecution(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("complete execution successfully", func(t *testing.T) { - robot := createTestRobot("test_complete_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - // Simulate execution progress - exec.Delivery = &types.DeliveryResult{ - RequestID: "test-delivery-001", - Content: &types.DeliveryContent{ - Summary: "Test delivery completed", - Body: "# Test Delivery\n\nThis is a test delivery result.", - }, - Success: true, - } - - err = job.CompleteExecution(ctx, exec) - require.NoError(t, err) - - assert.Equal(t, types.ExecCompleted, exec.Status) - assert.NotNil(t, exec.EndTime) - - // Verify job was completed - j, err := job.Get(exec.JobID) - require.NoError(t, err) - assert.Equal(t, "completed", j.Status) - - // Verify job execution was updated - jobExec, err := job.GetExecution(exec.ID) - require.NoError(t, err) - assert.Equal(t, "completed", jobExec.Status) - assert.Equal(t, 100, jobExec.Progress) - assert.NotNil(t, jobExec.EndedAt) - }) - - t.Run("complete execution with nil execution returns error", func(t *testing.T) { - err := job.CompleteExecution(ctx, nil) - assert.Error(t, err) - }) -} - -// TestFailExecution tests failing an execution -func TestFailExecution(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("fail execution with error", func(t *testing.T) { - robot := createTestRobot("test_fail_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - testErr := errors.New("task execution failed") - err = job.FailExecution(ctx, exec, testErr) - require.NoError(t, err) - - assert.Equal(t, types.ExecFailed, exec.Status) - assert.NotNil(t, exec.EndTime) - assert.Equal(t, testErr.Error(), exec.Error) - - // Verify job was failed - j, err := job.Get(exec.JobID) - require.NoError(t, err) - assert.Equal(t, "failed", j.Status) - }) - - t.Run("fail execution without error", func(t *testing.T) { - robot := createTestRobot("test_fail_002") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.FailExecution(ctx, exec, nil) - require.NoError(t, err) - - assert.Equal(t, types.ExecFailed, exec.Status) - assert.Empty(t, exec.Error) - }) -} - -// TestCancelExecution tests cancelling an execution -func TestCancelExecution(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("cancel execution successfully", func(t *testing.T) { - robot := createTestRobot("test_cancel_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.CancelExecution(ctx, exec) - require.NoError(t, err) - - assert.Equal(t, types.ExecCancelled, exec.Status) - assert.NotNil(t, exec.EndTime) - - // Verify job was cancelled - j, err := job.Get(exec.JobID) - require.NoError(t, err) - assert.Equal(t, "cancelled", j.Status) - }) -} - -// TestGetExecution tests retrieving an execution -func TestGetExecution(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("get existing execution", func(t *testing.T) { - robot := createTestRobot("test_get_exec_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - jobExec, err := job.GetExecution(exec.ID) - require.NoError(t, err) - assert.NotNil(t, jobExec) - assert.Equal(t, exec.ID, jobExec.ExecutionID) - assert.Equal(t, exec.JobID, jobExec.JobID) - }) - - t.Run("get non-existent execution returns error", func(t *testing.T) { - _, err := job.GetExecution("non_existent_exec_id") - assert.Error(t, err) - }) - - t.Run("get with empty execution ID returns error", func(t *testing.T) { - _, err := job.GetExecution("") - assert.Error(t, err) - assert.Contains(t, err.Error(), "execution ID is empty") - }) -} - -// TestListExecutions tests listing executions for a job -func TestListExecutions(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("list executions for job", func(t *testing.T) { - robot := createTestRobot("test_list_exec_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - execs, err := job.ListExecutions(exec.JobID) - require.NoError(t, err) - assert.NotEmpty(t, execs) - assert.Equal(t, 1, len(execs)) - assert.Equal(t, exec.ID, execs[0].ExecutionID) - }) - - t.Run("list with empty job ID returns error", func(t *testing.T) { - _, err := job.ListExecutions("") - assert.Error(t, err) - assert.Contains(t, err.Error(), "job ID is empty") - }) -} - -// TestPhaseToProgress tests phase to progress mapping -func TestPhaseToProgress(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - testCases := []struct { - phase types.Phase - expectedProgress int - }{ - {types.PhaseInspiration, 10}, - {types.PhaseGoals, 25}, - {types.PhaseTasks, 40}, - {types.PhaseRun, 60}, - {types.PhaseDelivery, 80}, - {types.PhaseLearning, 95}, - } - - for _, tc := range testCases { - t.Run(string(tc.phase), func(t *testing.T) { - robot := createTestRobot("test_progress_" + string(tc.phase)) - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.UpdatePhase(ctx, exec, tc.phase) - require.NoError(t, err) - - // Verify progress in job execution - jobExec, err := job.GetExecution(exec.ID) - require.NoError(t, err) - assert.Equal(t, tc.expectedProgress, jobExec.Progress) - }) - } -} - -// TestExecutionDuration tests execution duration calculation -func TestExecutionDuration(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("duration calculated on completion", func(t *testing.T) { - robot := createTestRobot("test_duration_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - // Wait a bit to ensure measurable duration - time.Sleep(50 * time.Millisecond) - - err = job.CompleteExecution(ctx, exec) - require.NoError(t, err) - - // Verify duration was calculated - jobExec, err := job.GetExecution(exec.ID) - require.NoError(t, err) - assert.NotNil(t, jobExec.Duration) - assert.Greater(t, *jobExec.Duration, 0) - }) - - t.Run("duration calculated on failure", func(t *testing.T) { - robot := createTestRobot("test_duration_002") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - time.Sleep(50 * time.Millisecond) - - err = job.FailExecution(ctx, exec, errors.New("test error")) - require.NoError(t, err) - - jobExec, err := job.GetExecution(exec.ID) - require.NoError(t, err) - assert.NotNil(t, jobExec.Duration) - assert.Greater(t, *jobExec.Duration, 0) - }) -} diff --git a/agent/robot/job/job.go b/agent/robot/job/job.go deleted file mode 100644 index 1a2d1fae..00000000 --- a/agent/robot/job/job.go +++ /dev/null @@ -1,365 +0,0 @@ -package job - -import ( - "fmt" - "strings" - - gonanoid "github.com/matoous/go-nanoid/v2" - yaojob "github.com/yaoapp/yao/job" - - "github.com/yaoapp/yao/agent/robot/types" -) - -// CategoryID is the job category for robot executions -const CategoryID = "autonomous_robot" - -// JobIDPrefix is the prefix for robot job IDs -const JobIDPrefix = "robot_exec_" - -// Options holds options for creating a new job -type Options struct { - Robot *types.Robot // Required: the robot to execute - TriggerType types.TriggerType // Required: clock | human | event - - // Optional fields for future extension - Priority int // Job priority (higher = more important) - MaxRetryCount int // Max retry count on failure - DefaultTimeout *int // Default execution timeout in seconds - Metadata map[string]interface{} // Custom metadata stored in job config -} - -// Validate validates the Options -func (o *Options) Validate() error { - if o.Robot == nil { - return fmt.Errorf("robot is required") - } - if o.TriggerType == "" { - return fmt.Errorf("trigger type is required") - } - return nil -} - -// Create creates a new job for robot execution -// Returns the job ID (format: robot_exec_{execID}) and the generated execution ID -func Create(ctx *types.Context, opts *Options) (jobID string, execID string, err error) { - if opts == nil { - return "", "", fmt.Errorf("options is nil") - } - if err := opts.Validate(); err != nil { - return "", "", err - } - - robot := opts.Robot - triggerType := opts.TriggerType - - // Generate execution ID - execID, err = gonanoid.New() - if err != nil { - return "", "", fmt.Errorf("failed to generate execution ID: %w", err) - } - - // Create job ID: robot_exec_{execID} - jobID = JobIDPrefix + execID - - // Get locale from context - locale := getLocale(ctx) - - // Build job name based on locale - // Use robot display name for better readability in Activity Monitor - displayName := robot.DisplayName - if displayName == "" { - displayName = robot.MemberID - } - name := buildJobName(locale, triggerType, displayName) - - // Build job config - jobConfig := map[string]interface{}{ - "member_id": robot.MemberID, - "team_id": robot.TeamID, - "trigger_type": string(triggerType), - "exec_id": execID, - "display_name": displayName, - } - - // Merge custom metadata into config - if opts.Metadata != nil { - for k, v := range opts.Metadata { - jobConfig[k] = v - } - } - - // Build job params - jobParams := map[string]interface{}{ - "job_id": jobID, - "category_name": getCategoryName(locale), - "name": name, - "config": jobConfig, - } - - // Apply optional fields - if opts.Priority > 0 { - jobParams["priority"] = opts.Priority - } - if opts.MaxRetryCount > 0 { - jobParams["max_retry_count"] = opts.MaxRetryCount - } - if opts.DefaultTimeout != nil { - jobParams["default_timeout"] = *opts.DefaultTimeout - } - - // Create job using yao/job package - j, err := yaojob.Once(yaojob.GOROUTINE, jobParams) - if err != nil { - return "", "", fmt.Errorf("failed to create job: %w", err) - } - - // Save job to database - if err := yaojob.SaveJob(j); err != nil { - return "", "", fmt.Errorf("failed to save job: %w", err) - } - - return jobID, execID, nil -} - -// Get retrieves a job by job ID -func Get(jobID string) (*yaojob.Job, error) { - if jobID == "" { - return nil, fmt.Errorf("job ID is empty") - } - return yaojob.GetJob(jobID) -} - -// Update updates job status and phase -func Update(ctx *types.Context, exec *types.Execution) error { - if exec == nil || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing job ID") - } - - j, err := yaojob.GetJob(exec.JobID) - if err != nil { - return fmt.Errorf("failed to get job: %w", err) - } - - // Map robot status to job status - jobStatus := mapStatusToJobStatus(exec.Status) - j.Status = jobStatus - - // Update config with current phase - if j.Config == nil { - j.Config = make(map[string]interface{}) - } - j.Config["current_phase"] = string(exec.Phase) - j.Config["current_status"] = string(exec.Status) - - if err := yaojob.SaveJob(j); err != nil { - return fmt.Errorf("failed to update job: %w", err) - } - - return nil -} - -// Complete marks job as completed -func Complete(ctx *types.Context, exec *types.Execution) error { - if exec == nil || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing job ID") - } - - j, err := yaojob.GetJob(exec.JobID) - if err != nil { - return fmt.Errorf("failed to get job: %w", err) - } - - j.Status = "completed" - - // Update config with final state - if j.Config == nil { - j.Config = make(map[string]interface{}) - } - j.Config["current_phase"] = string(types.PhaseLearning) - j.Config["current_status"] = string(types.ExecCompleted) - - if exec.Delivery != nil { - j.Config["delivery_success"] = exec.Delivery.Success - } - - if err := yaojob.SaveJob(j); err != nil { - return fmt.Errorf("failed to complete job: %w", err) - } - - return nil -} - -// Fail marks job as failed -func Fail(ctx *types.Context, exec *types.Execution, execErr error) error { - if exec == nil || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing job ID") - } - - j, err := yaojob.GetJob(exec.JobID) - if err != nil { - return fmt.Errorf("failed to get job: %w", err) - } - - j.Status = "failed" - - // Update config with error info - if j.Config == nil { - j.Config = make(map[string]interface{}) - } - j.Config["current_phase"] = string(exec.Phase) - j.Config["current_status"] = string(types.ExecFailed) - if execErr != nil { - j.Config["error"] = execErr.Error() - } - - if err := yaojob.SaveJob(j); err != nil { - return fmt.Errorf("failed to fail job: %w", err) - } - - return nil -} - -// Cancel marks job as cancelled -func Cancel(ctx *types.Context, exec *types.Execution) error { - if exec == nil || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing job ID") - } - - j, err := yaojob.GetJob(exec.JobID) - if err != nil { - return fmt.Errorf("failed to get job: %w", err) - } - - j.Status = "cancelled" - - // Update config with cancelled state - if j.Config == nil { - j.Config = make(map[string]interface{}) - } - j.Config["current_phase"] = string(exec.Phase) - j.Config["current_status"] = string(types.ExecCancelled) - - if err := yaojob.SaveJob(j); err != nil { - return fmt.Errorf("failed to cancel job: %w", err) - } - - return nil -} - -// mapStatusToJobStatus maps robot ExecStatus to job status string -// Job model ENUM values: draft, ready, queued, running, paused, completed, failed, cancelled, disabled -func mapStatusToJobStatus(status types.ExecStatus) string { - switch status { - case types.ExecPending: - return "queued" - case types.ExecRunning: - return "running" - case types.ExecCompleted: - return "completed" - case types.ExecFailed: - return "failed" - case types.ExecCancelled: - return "cancelled" - default: - return "draft" - } -} - -// getLocale returns the locale from context, defaults to "en-US" -func getLocale(ctx *types.Context) string { - if ctx == nil || ctx.Locale == "" { - return "en-US" - } - return ctx.Locale -} - -// isChineseLocale checks if the locale is Chinese -func isChineseLocale(locale string) bool { - return strings.HasPrefix(strings.ToLower(locale), "zh") -} - -// buildJobName builds the job name based on locale -func buildJobName(locale string, triggerType types.TriggerType, displayName string) string { - var name string - if isChineseLocale(locale) { - name = fmt.Sprintf("机器人执行 - %s", getTriggerTypeName(locale, triggerType)) - } else { - name = fmt.Sprintf("Robot Execution - %s", getTriggerTypeName(locale, triggerType)) - } - if displayName != "" { - name = fmt.Sprintf("%s (%s)", name, displayName) - } - return name -} - -// getCategoryName returns the category name based on locale -func getCategoryName(locale string) string { - if isChineseLocale(locale) { - return "自主机器人" - } - return "Autonomous Robot" -} - -// getTriggerTypeName returns the trigger type name based on locale -func getTriggerTypeName(locale string, triggerType types.TriggerType) string { - if isChineseLocale(locale) { - switch triggerType { - case types.TriggerClock: - return "定时触发" - case types.TriggerHuman: - return "人工触发" - case types.TriggerEvent: - return "事件触发" - default: - return string(triggerType) - } - } - switch triggerType { - case types.TriggerClock: - return "Clock" - case types.TriggerHuman: - return "Human" - case types.TriggerEvent: - return "Event" - default: - return string(triggerType) - } -} - -// getPhaseName returns the phase name based on locale -func getPhaseName(locale string, phase types.Phase) string { - if isChineseLocale(locale) { - switch phase { - case types.PhaseInspiration: - return "灵感收集" - case types.PhaseGoals: - return "目标生成" - case types.PhaseTasks: - return "任务规划" - case types.PhaseRun: - return "任务执行" - case types.PhaseDelivery: - return "结果交付" - case types.PhaseLearning: - return "学习总结" - default: - return string(phase) - } - } - switch phase { - case types.PhaseInspiration: - return "Inspiration" - case types.PhaseGoals: - return "Goals" - case types.PhaseTasks: - return "Tasks" - case types.PhaseRun: - return "Run" - case types.PhaseDelivery: - return "Delivery" - case types.PhaseLearning: - return "Learning" - default: - return string(phase) - } -} diff --git a/agent/robot/job/job_test.go b/agent/robot/job/job_test.go deleted file mode 100644 index 54bccbe2..00000000 --- a/agent/robot/job/job_test.go +++ /dev/null @@ -1,508 +0,0 @@ -package job_test - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/yaoapp/yao/agent/robot/job" - "github.com/yaoapp/yao/agent/robot/types" - "github.com/yaoapp/yao/agent/testutils" -) - -// TestJobCreate tests creating a new job -func TestJobCreate(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("create job with clock trigger", func(t *testing.T) { - robot := createTestRobot("test_job_create_001") - - jobID, execID, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - - require.NoError(t, err) - assert.NotEmpty(t, jobID) - assert.NotEmpty(t, execID) - assert.Contains(t, jobID, job.JobIDPrefix) - assert.Contains(t, jobID, execID) - - // Verify job was created in database - j, err := job.Get(jobID) - require.NoError(t, err) - assert.NotNil(t, j) - assert.Equal(t, jobID, j.JobID) - assert.Equal(t, robot.MemberID, j.Config["member_id"]) - assert.Equal(t, robot.TeamID, j.Config["team_id"]) - assert.Equal(t, string(types.TriggerClock), j.Config["trigger_type"]) - }) - - t.Run("create job with human trigger", func(t *testing.T) { - robot := createTestRobot("test_job_create_002") - - jobID, execID, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerHuman, - }) - - require.NoError(t, err) - assert.NotEmpty(t, jobID) - assert.NotEmpty(t, execID) - - j, err := job.Get(jobID) - require.NoError(t, err) - assert.Equal(t, string(types.TriggerHuman), j.Config["trigger_type"]) - }) - - t.Run("create job with event trigger", func(t *testing.T) { - robot := createTestRobot("test_job_create_003") - - jobID, execID, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerEvent, - }) - - require.NoError(t, err) - assert.NotEmpty(t, jobID) - assert.NotEmpty(t, execID) - - j, err := job.Get(jobID) - require.NoError(t, err) - assert.Equal(t, string(types.TriggerEvent), j.Config["trigger_type"]) - }) - - t.Run("create job with priority and metadata", func(t *testing.T) { - robot := createTestRobot("test_job_create_004") - - jobID, _, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerClock, - Priority: 10, - Metadata: map[string]interface{}{ - "custom_key": "custom_value", - }, - }) - - require.NoError(t, err) - - j, err := job.Get(jobID) - require.NoError(t, err) - assert.Equal(t, 10, j.Priority) - assert.Equal(t, "custom_value", j.Config["custom_key"]) - }) - - t.Run("create job with nil robot returns error", func(t *testing.T) { - _, _, err := job.Create(ctx, &job.Options{ - Robot: nil, - TriggerType: types.TriggerClock, - }) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "robot is required") - }) - - t.Run("create job with empty trigger type returns error", func(t *testing.T) { - robot := createTestRobot("test_job_create_005") - - _, _, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: "", - }) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "trigger type is required") - }) - - t.Run("create job with nil options returns error", func(t *testing.T) { - _, _, err := job.Create(ctx, nil) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "options is nil") - }) -} - -// TestJobGet tests retrieving a job by ID -func TestJobGet(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("get existing job", func(t *testing.T) { - robot := createTestRobot("test_job_get_001") - jobID, _, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - j, err := job.Get(jobID) - require.NoError(t, err) - assert.NotNil(t, j) - assert.Equal(t, jobID, j.JobID) - }) - - t.Run("get non-existent job returns error", func(t *testing.T) { - _, err := job.Get("non_existent_job_id") - assert.Error(t, err) - }) - - t.Run("get with empty job ID returns error", func(t *testing.T) { - _, err := job.Get("") - assert.Error(t, err) - assert.Contains(t, err.Error(), "job ID is empty") - }) -} - -// TestJobUpdate tests updating job status and phase -func TestJobUpdate(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("update job status and phase", func(t *testing.T) { - robot := createTestRobot("test_job_update_001") - jobID, execID, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - exec := &types.Execution{ - ID: execID, - JobID: jobID, - Status: types.ExecRunning, - Phase: types.PhaseGoals, - } - - err = job.Update(ctx, exec) - require.NoError(t, err) - - // Verify update - j, err := job.Get(jobID) - require.NoError(t, err) - assert.Equal(t, "running", j.Status) - assert.Equal(t, string(types.PhaseGoals), j.Config["current_phase"]) - assert.Equal(t, string(types.ExecRunning), j.Config["current_status"]) - }) - - t.Run("update with nil execution returns error", func(t *testing.T) { - err := job.Update(ctx, nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid execution") - }) - - t.Run("update with empty job ID returns error", func(t *testing.T) { - exec := &types.Execution{ - ID: "some_id", - JobID: "", - Status: types.ExecRunning, - Phase: types.PhaseGoals, - } - err := job.Update(ctx, exec) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid execution") - }) -} - -// TestJobComplete tests completing a job -func TestJobComplete(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("complete job successfully", func(t *testing.T) { - robot := createTestRobot("test_job_complete_001") - jobID, execID, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - exec := &types.Execution{ - ID: execID, - JobID: jobID, - Delivery: &types.DeliveryResult{ - Success: true, - }, - } - - err = job.Complete(ctx, exec) - require.NoError(t, err) - - // Verify completion - j, err := job.Get(jobID) - require.NoError(t, err) - assert.Equal(t, "completed", j.Status) - assert.Equal(t, string(types.PhaseLearning), j.Config["current_phase"]) - assert.Equal(t, string(types.ExecCompleted), j.Config["current_status"]) - assert.Equal(t, true, j.Config["delivery_success"]) - }) - - t.Run("complete with nil execution returns error", func(t *testing.T) { - err := job.Complete(ctx, nil) - assert.Error(t, err) - }) -} - -// TestJobFail tests failing a job -func TestJobFail(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("fail job with error", func(t *testing.T) { - robot := createTestRobot("test_job_fail_001") - jobID, execID, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - exec := &types.Execution{ - ID: execID, - JobID: jobID, - Phase: types.PhaseRun, - } - - testErr := assert.AnError - err = job.Fail(ctx, exec, testErr) - require.NoError(t, err) - - // Verify failure - j, err := job.Get(jobID) - require.NoError(t, err) - assert.Equal(t, "failed", j.Status) - assert.Equal(t, string(types.PhaseRun), j.Config["current_phase"]) - assert.Equal(t, string(types.ExecFailed), j.Config["current_status"]) - assert.NotEmpty(t, j.Config["error"]) - }) - - t.Run("fail job without error message", func(t *testing.T) { - robot := createTestRobot("test_job_fail_002") - jobID, execID, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - exec := &types.Execution{ - ID: execID, - JobID: jobID, - Phase: types.PhaseDelivery, - } - - err = job.Fail(ctx, exec, nil) - require.NoError(t, err) - - j, err := job.Get(jobID) - require.NoError(t, err) - assert.Equal(t, "failed", j.Status) - assert.Nil(t, j.Config["error"]) - }) -} - -// TestJobCancel tests cancelling a job -func TestJobCancel(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("cancel job successfully", func(t *testing.T) { - robot := createTestRobot("test_job_cancel_001") - jobID, execID, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - // First verify job was created - j, err := job.Get(jobID) - require.NoError(t, err) - t.Logf("Job ID: %d, Status before cancel: %s, Config: %v", j.ID, j.Status, j.Config) - - exec := &types.Execution{ - ID: execID, - JobID: jobID, - Phase: types.PhaseTasks, - } - - err = job.Cancel(ctx, exec) - require.NoError(t, err) - - // Verify cancellation - check config since status might not be returned correctly - j, err = job.Get(jobID) - require.NoError(t, err) - t.Logf("Job ID: %d, Status after cancel: %s, Config: %v", j.ID, j.Status, j.Config) - - // Check config values which should be correctly updated - assert.Equal(t, string(types.PhaseTasks), j.Config["current_phase"]) - assert.Equal(t, string(types.ExecCancelled), j.Config["current_status"]) - }) -} - -// TestJobLocalization tests job name localization -func TestJobLocalization(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - t.Run("english locale", func(t *testing.T) { - ctx := &types.Context{ - Context: context.Background(), - Locale: "en-US", - } - robot := createTestRobot("test_job_locale_en") - robot.DisplayName = "Sales Bot" - - jobID, _, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - j, err := job.Get(jobID) - require.NoError(t, err) - assert.Contains(t, j.Name, "Robot Execution") - assert.Contains(t, j.Name, "Clock") - assert.Contains(t, j.Name, "Sales Bot") - }) - - t.Run("chinese locale", func(t *testing.T) { - ctx := &types.Context{ - Context: context.Background(), - Locale: "zh-CN", - } - robot := createTestRobot("test_job_locale_zh") - robot.DisplayName = "销售机器人" - - jobID, _, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerHuman, - }) - require.NoError(t, err) - - j, err := job.Get(jobID) - require.NoError(t, err) - assert.Contains(t, j.Name, "机器人执行") - assert.Contains(t, j.Name, "人工触发") - assert.Contains(t, j.Name, "销售机器人") - }) -} - -// TestMapStatusToJobStatus tests status mapping via config -func TestMapStatusToJobStatus(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - testCases := []struct { - status types.ExecStatus - expectedStatus string // This is the raw ExecStatus string stored in config["current_status"] - }{ - {types.ExecPending, "pending"}, - {types.ExecRunning, "running"}, - {types.ExecCompleted, "completed"}, - {types.ExecFailed, "failed"}, - {types.ExecCancelled, "cancelled"}, - } - - for _, tc := range testCases { - t.Run(string(tc.status), func(t *testing.T) { - robot := createTestRobot("test_status_map_" + string(tc.status)) - jobID, execID, err := job.Create(ctx, &job.Options{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - exec := &types.Execution{ - ID: execID, - JobID: jobID, - Status: tc.status, - Phase: types.PhaseInspiration, - } - - err = job.Update(ctx, exec) - require.NoError(t, err) - - j, err := job.Get(jobID) - require.NoError(t, err) - // Verify status is stored in config (since Job.Status field may not be reliably returned) - assert.Equal(t, tc.expectedStatus, j.Config["current_status"]) - }) - } -} - -// createTestRobot creates a test robot for testing -func createTestRobot(memberID string) *types.Robot { - return &types.Robot{ - MemberID: memberID, - TeamID: "test_team_001", - DisplayName: "Test Robot " + memberID, - SystemPrompt: "You are a test robot.", - Status: types.RobotIdle, - AutonomousMode: true, - Config: &types.Config{ - Triggers: &types.Triggers{ - Clock: &types.TriggerSwitch{Enabled: true}, - Intervene: &types.TriggerSwitch{Enabled: true}, - Event: &types.TriggerSwitch{Enabled: true}, - }, - Identity: &types.Identity{ - Role: "Test Role", - }, - Quota: &types.Quota{ - Max: 2, - }, - }, - } -} - -// cleanupTestJobs cleans up test jobs from database -func cleanupTestJobs(t *testing.T) { - // Jobs are auto-cleaned by yao/job package - // This is a placeholder for any additional cleanup -} diff --git a/agent/robot/job/log.go b/agent/robot/job/log.go deleted file mode 100644 index 38af273f..00000000 --- a/agent/robot/job/log.go +++ /dev/null @@ -1,296 +0,0 @@ -package job - -import ( - "encoding/json" - "fmt" - "time" - - yaojob "github.com/yaoapp/yao/job" - - "github.com/yaoapp/yao/agent/robot/types" -) - -// Log writes a log entry for the execution -func Log(ctx *types.Context, exec *types.Execution, level string, message string, data map[string]interface{}) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - // Build context JSON with execution_id included - if data == nil { - data = make(map[string]interface{}) - } - data["execution_id"] = exec.ID - - var contextRaw *json.RawMessage - contextBytes, err := json.Marshal(data) - if err == nil { - raw := json.RawMessage(contextBytes) - contextRaw = &raw - } - - // Extract step from data if available - var step *string - if s, ok := data["step"].(string); ok { - step = &s - } - - logEntry := &yaojob.Log{ - JobID: exec.JobID, - Level: level, - Message: message, - Context: contextRaw, - ExecutionID: &exec.ID, - Step: step, - Timestamp: time.Now(), - Sequence: 0, - } - - return yaojob.SaveLog(logEntry) -} - -// LogPhaseStart logs the start of a phase -func LogPhaseStart(ctx *types.Context, exec *types.Execution, phase types.Phase) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - locale := getLocale(ctx) - phaseName := getPhaseName(locale, phase) - - var message string - if isChineseLocale(locale) { - message = fmt.Sprintf("阶段开始: %s", phaseName) - } else { - message = fmt.Sprintf("Phase started: %s", phaseName) - } - - return Log(ctx, exec, "info", message, map[string]interface{}{ - "phase": string(phase), - "phase_name": phaseName, - "step": fmt.Sprintf("phase_%s_start", phase), - "event": "phase_start", - }) -} - -// LogPhaseEnd logs the end of a phase -func LogPhaseEnd(ctx *types.Context, exec *types.Execution, phase types.Phase, durationMs int64) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - locale := getLocale(ctx) - phaseName := getPhaseName(locale, phase) - - var message string - if isChineseLocale(locale) { - message = fmt.Sprintf("阶段完成: %s", phaseName) - } else { - message = fmt.Sprintf("Phase completed: %s", phaseName) - } - - return Log(ctx, exec, "info", message, map[string]interface{}{ - "phase": string(phase), - "phase_name": phaseName, - "step": fmt.Sprintf("phase_%s_end", phase), - "event": "phase_end", - "duration_ms": durationMs, - }) -} - -// LogPhaseError logs a phase error -func LogPhaseError(ctx *types.Context, exec *types.Execution, phase types.Phase, err error) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - locale := getLocale(ctx) - phaseName := getPhaseName(locale, phase) - - errMsg := "unknown error" - if err != nil { - errMsg = err.Error() - } - - var message string - if isChineseLocale(locale) { - message = fmt.Sprintf("阶段失败: %s - %s", phaseName, errMsg) - } else { - message = fmt.Sprintf("Phase failed: %s - %s", phaseName, errMsg) - } - - return Log(ctx, exec, "error", message, map[string]interface{}{ - "phase": string(phase), - "phase_name": phaseName, - "step": fmt.Sprintf("phase_%s_error", phase), - "event": "phase_error", - "error": errMsg, - }) -} - -// LogError logs an error -func LogError(ctx *types.Context, exec *types.Execution, err error) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - locale := getLocale(ctx) - errMsg := "unknown error" - if err != nil { - errMsg = err.Error() - } - - var message string - if isChineseLocale(locale) { - message = fmt.Sprintf("错误: %s", errMsg) - } else { - message = errMsg - } - - return Log(ctx, exec, "error", message, map[string]interface{}{ - "event": "error", - "error": errMsg, - }) -} - -// LogInfo logs an info message -func LogInfo(ctx *types.Context, exec *types.Execution, message string) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - return Log(ctx, exec, "info", message, nil) -} - -// LogDebug logs a debug message -func LogDebug(ctx *types.Context, exec *types.Execution, message string) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - return Log(ctx, exec, "debug", message, nil) -} - -// LogWarn logs a warning message -func LogWarn(ctx *types.Context, exec *types.Execution, message string) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - return Log(ctx, exec, "warning", message, nil) -} - -// LogTaskStart logs the start of a task -func LogTaskStart(ctx *types.Context, exec *types.Execution, taskID string, taskOrder int) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - locale := getLocale(ctx) - - var message string - if isChineseLocale(locale) { - message = fmt.Sprintf("任务开始: %s", taskID) - } else { - message = fmt.Sprintf("Task started: %s", taskID) - } - - return Log(ctx, exec, "info", message, map[string]interface{}{ - "task_id": taskID, - "task_order": taskOrder, - "step": fmt.Sprintf("task_%d_start", taskOrder), - "event": "task_start", - }) -} - -// LogTaskEnd logs the end of a task -func LogTaskEnd(ctx *types.Context, exec *types.Execution, taskID string, taskOrder int, success bool, durationMs int64) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - locale := getLocale(ctx) - level := "info" - event := "task_success" - - var msg string - if isChineseLocale(locale) { - if success { - msg = fmt.Sprintf("任务完成: %s", taskID) - } else { - level = "warning" - event = "task_failed" - msg = fmt.Sprintf("任务失败: %s", taskID) - } - } else { - if success { - msg = fmt.Sprintf("Task completed: %s", taskID) - } else { - level = "warning" - event = "task_failed" - msg = fmt.Sprintf("Task failed: %s", taskID) - } - } - - return Log(ctx, exec, level, msg, map[string]interface{}{ - "task_id": taskID, - "task_order": taskOrder, - "step": fmt.Sprintf("task_%d_end", taskOrder), - "event": event, - "success": success, - "duration_ms": durationMs, - }) -} - -// LogDelivery logs delivery result -func LogDelivery(ctx *types.Context, exec *types.Execution, deliveryType string, success bool) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - locale := getLocale(ctx) - level := "info" - - var msg string - if isChineseLocale(locale) { - if success { - msg = fmt.Sprintf("交付完成: %s", deliveryType) - } else { - level = "warning" - msg = fmt.Sprintf("交付失败: %s", deliveryType) - } - } else { - if success { - msg = fmt.Sprintf("Delivery completed: %s", deliveryType) - } else { - level = "warning" - msg = fmt.Sprintf("Delivery failed: %s", deliveryType) - } - } - - return Log(ctx, exec, level, msg, map[string]interface{}{ - "delivery_type": deliveryType, - "step": "delivery", - "event": "delivery", - "success": success, - }) -} - -// LogLearning logs learning result -func LogLearning(ctx *types.Context, exec *types.Execution, entriesCount int) error { - if exec == nil || exec.ID == "" || exec.JobID == "" { - return fmt.Errorf("invalid execution or missing execution/job ID") - } - - locale := getLocale(ctx) - - var msg string - if isChineseLocale(locale) { - msg = fmt.Sprintf("学习保存: %d 条记录", entriesCount) - } else { - msg = fmt.Sprintf("Learning saved: %d entries", entriesCount) - } - - return Log(ctx, exec, "info", msg, map[string]interface{}{ - "entries_count": entriesCount, - "step": "learning", - "event": "learning", - }) -} diff --git a/agent/robot/job/log_test.go b/agent/robot/job/log_test.go deleted file mode 100644 index 1aa11138..00000000 --- a/agent/robot/job/log_test.go +++ /dev/null @@ -1,771 +0,0 @@ -package job_test - -import ( - "context" - "errors" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/yaoapp/gou/model" - "github.com/yaoapp/kun/maps" - yaojob "github.com/yaoapp/yao/job" - - "github.com/yaoapp/yao/agent/robot/job" - "github.com/yaoapp/yao/agent/robot/types" - "github.com/yaoapp/yao/agent/testutils" -) - -// TestLog tests writing log entries -func TestLog(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("write info log", func(t *testing.T) { - robot := createTestRobot("test_log_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.Log(ctx, exec, "info", "Test message", map[string]interface{}{ - "key": "value", - }) - require.NoError(t, err) - - // Verify log was written - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - assert.NotEmpty(t, logs) - - found := false - for _, log := range logs { - if log.Message == "Test message" && log.Level == "info" { - found = true - break - } - } - assert.True(t, found, "Log entry should be found") - }) - - t.Run("write error log", func(t *testing.T) { - robot := createTestRobot("test_log_002") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.Log(ctx, exec, "error", "Error occurred", nil) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Message == "Error occurred" && log.Level == "error" { - found = true - break - } - } - assert.True(t, found, "Error log entry should be found") - }) - - t.Run("log with nil execution returns error", func(t *testing.T) { - err := job.Log(ctx, nil, "info", "Test", nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid execution") - }) - - t.Run("log with empty job ID returns error", func(t *testing.T) { - exec := &types.Execution{ - ID: "some_id", - JobID: "", - } - err := job.Log(ctx, exec, "info", "Test", nil) - assert.Error(t, err) - }) -} - -// TestLogPhaseStart tests logging phase start -func TestLogPhaseStart(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log phase start in english", func(t *testing.T) { - ctx := &types.Context{ - Context: context.Background(), - Locale: "en-US", - } - robot := createTestRobot("test_phase_log_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogPhaseStart(ctx, exec, types.PhaseGoals) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "info" && containsString(log.Message, "Phase started") && containsString(log.Message, "Goals") { - found = true - break - } - } - assert.True(t, found, "Phase start log should be found") - }) - - t.Run("log phase start in chinese", func(t *testing.T) { - ctx := &types.Context{ - Context: context.Background(), - Locale: "zh-CN", - } - robot := createTestRobot("test_phase_log_002") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogPhaseStart(ctx, exec, types.PhaseGoals) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "info" && containsString(log.Message, "阶段开始") { - found = true - break - } - } - assert.True(t, found, "Chinese phase start log should be found") - }) - - t.Run("log phase start with nil execution returns error", func(t *testing.T) { - err := job.LogPhaseStart(ctx, nil, types.PhaseGoals) - assert.Error(t, err) - }) -} - -// TestLogPhaseEnd tests logging phase end -func TestLogPhaseEnd(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log phase end with duration", func(t *testing.T) { - robot := createTestRobot("test_phase_end_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogPhaseEnd(ctx, exec, types.PhaseInspiration, 1500) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "info" && (containsString(log.Message, "Phase completed") || containsString(log.Message, "阶段完成")) { - found = true - break - } - } - assert.True(t, found, "Phase end log should be found") - }) -} - -// TestLogPhaseError tests logging phase error -func TestLogPhaseError(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log phase error", func(t *testing.T) { - robot := createTestRobot("test_phase_err_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - testErr := errors.New("goal generation failed") - err = job.LogPhaseError(ctx, exec, types.PhaseGoals, testErr) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "error" && containsString(log.Message, "goal generation failed") { - found = true - break - } - } - assert.True(t, found, "Phase error log should be found") - }) - - t.Run("log phase error with nil error", func(t *testing.T) { - robot := createTestRobot("test_phase_err_002") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogPhaseError(ctx, exec, types.PhaseGoals, nil) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "error" && containsString(log.Message, "unknown error") { - found = true - break - } - } - assert.True(t, found, "Phase error log with unknown error should be found") - }) -} - -// TestLogError tests logging errors -func TestLogError(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log error", func(t *testing.T) { - robot := createTestRobot("test_error_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - testErr := errors.New("connection timeout") - err = job.LogError(ctx, exec, testErr) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "error" && containsString(log.Message, "connection timeout") { - found = true - break - } - } - assert.True(t, found, "Error log should be found") - }) -} - -// TestLogInfo tests logging info messages -func TestLogInfo(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log info message", func(t *testing.T) { - robot := createTestRobot("test_info_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogInfo(ctx, exec, "Processing started") - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "info" && log.Message == "Processing started" { - found = true - break - } - } - assert.True(t, found, "Info log should be found") - }) -} - -// TestLogDebug tests logging debug messages -func TestLogDebug(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log debug message", func(t *testing.T) { - robot := createTestRobot("test_debug_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogDebug(ctx, exec, "Debug info") - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "debug" && log.Message == "Debug info" { - found = true - break - } - } - assert.True(t, found, "Debug log should be found") - }) -} - -// TestLogWarn tests logging warning messages -func TestLogWarn(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log warning message", func(t *testing.T) { - robot := createTestRobot("test_warn_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogWarn(ctx, exec, "Resource running low") - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "warning" && log.Message == "Resource running low" { - found = true - break - } - } - assert.True(t, found, "Warning log should be found") - }) -} - -// TestLogTaskStart tests logging task start -func TestLogTaskStart(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log task start", func(t *testing.T) { - robot := createTestRobot("test_task_start_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogTaskStart(ctx, exec, "task_001", 1) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "info" && containsString(log.Message, "task_001") { - found = true - break - } - } - assert.True(t, found, "Task start log should be found") - }) -} - -// TestLogTaskEnd tests logging task end -func TestLogTaskEnd(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log task end success", func(t *testing.T) { - robot := createTestRobot("test_task_end_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogTaskEnd(ctx, exec, "task_001", 1, true, 500) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "info" && containsString(log.Message, "task_001") { - found = true - break - } - } - assert.True(t, found, "Task end success log should be found") - }) - - t.Run("log task end failure", func(t *testing.T) { - robot := createTestRobot("test_task_end_002") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogTaskEnd(ctx, exec, "task_002", 2, false, 300) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "warning" && containsString(log.Message, "task_002") { - found = true - break - } - } - assert.True(t, found, "Task end failure log should be found") - }) -} - -// TestLogDelivery tests logging delivery -func TestLogDelivery(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log delivery success", func(t *testing.T) { - robot := createTestRobot("test_delivery_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogDelivery(ctx, exec, "email", true) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "info" && containsString(log.Message, "email") { - found = true - break - } - } - assert.True(t, found, "Delivery success log should be found") - }) - - t.Run("log delivery failure", func(t *testing.T) { - robot := createTestRobot("test_delivery_002") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogDelivery(ctx, exec, "webhook", false) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "warning" && containsString(log.Message, "webhook") { - found = true - break - } - } - assert.True(t, found, "Delivery failure log should be found") - }) -} - -// TestLogLearning tests logging learning -func TestLogLearning(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - ctx := types.NewContext(context.Background(), nil) - - t.Run("log learning entries", func(t *testing.T) { - robot := createTestRobot("test_learning_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogLearning(ctx, exec, 5) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if log.Level == "info" && (containsString(log.Message, "5") || containsString(log.Message, "Learning")) { - found = true - break - } - } - assert.True(t, found, "Learning log should be found") - }) -} - -// TestLogLocalization tests log message localization -func TestLogLocalization(t *testing.T) { - if testing.Short() { - t.Skip("Skipping integration test") - } - - testutils.Prepare(t) - defer testutils.Clean(t) - - t.Run("english locale messages", func(t *testing.T) { - ctx := &types.Context{ - Context: context.Background(), - Locale: "en-US", - } - robot := createTestRobot("test_locale_en_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogPhaseStart(ctx, exec, types.PhaseRun) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if containsString(log.Message, "Phase started") && containsString(log.Message, "Run") { - found = true - break - } - } - assert.True(t, found, "English phase start message should be found") - }) - - t.Run("chinese locale messages", func(t *testing.T) { - ctx := &types.Context{ - Context: context.Background(), - Locale: "zh-CN", - } - robot := createTestRobot("test_locale_zh_001") - exec, err := job.CreateExecution(ctx, &job.CreateOptions{ - Robot: robot, - TriggerType: types.TriggerClock, - }) - require.NoError(t, err) - - err = job.LogPhaseStart(ctx, exec, types.PhaseRun) - require.NoError(t, err) - - logs, err := getJobLogs(exec.JobID) - require.NoError(t, err) - - found := false - for _, log := range logs { - if containsString(log.Message, "阶段开始") && containsString(log.Message, "任务执行") { - found = true - break - } - } - assert.True(t, found, "Chinese phase start message should be found") - }) -} - -// getJobLogs retrieves logs for a job -func getJobLogs(jobID string) ([]*yaojob.Log, error) { - result, err := yaojob.ListLogs(jobID, model.QueryParam{}, 1, 100) - if err != nil { - return nil, err - } - - data, exists := result["data"] - if !exists { - return nil, fmt.Errorf("ListLogs result missing 'data' field") - } - - // Handle nil data - if data == nil { - return []*yaojob.Log{}, nil - } - - // Handle different data types from ListLogs - var logs []*yaojob.Log - - switch typedData := data.(type) { - case []maps.MapStrAny: - for _, item := range typedData { - log := &yaojob.Log{} - if msg, ok := item["message"].(string); ok { - log.Message = msg - } - if level, ok := item["level"].(string); ok { - log.Level = level - } - if jid, ok := item["job_id"].(string); ok { - log.JobID = jid - } - logs = append(logs, log) - } - case []map[string]interface{}: - for _, item := range typedData { - log := &yaojob.Log{} - if msg, ok := item["message"].(string); ok { - log.Message = msg - } - if level, ok := item["level"].(string); ok { - log.Level = level - } - if jid, ok := item["job_id"].(string); ok { - log.JobID = jid - } - logs = append(logs, log) - } - case []interface{}: - // Handle generic []interface{} which may contain map types - for _, rawItem := range typedData { - log := &yaojob.Log{} - switch item := rawItem.(type) { - case maps.MapStrAny: - if msg, ok := item["message"].(string); ok { - log.Message = msg - } - if level, ok := item["level"].(string); ok { - log.Level = level - } - if jid, ok := item["job_id"].(string); ok { - log.JobID = jid - } - case map[string]interface{}: - if msg, ok := item["message"].(string); ok { - log.Message = msg - } - if level, ok := item["level"].(string); ok { - log.Level = level - } - if jid, ok := item["job_id"].(string); ok { - log.JobID = jid - } - default: - return nil, fmt.Errorf("unexpected item type in data array: %T", rawItem) - } - logs = append(logs, log) - } - default: - return nil, fmt.Errorf("unexpected data type from ListLogs: %T (value: %v)", data, data) - } - - return logs, nil -} - -// containsString checks if a string contains a substring -func containsString(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(substr) == 0 || - (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr))) -} - -func findSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} 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/agent/robot/store/execution.go b/agent/robot/store/execution.go index 3e97f7ba..adfa16d8 100644 --- a/agent/robot/store/execution.go +++ b/agent/robot/store/execution.go @@ -13,12 +13,11 @@ import ( // ExecutionRecord - persistent storage for robot execution history // Maps to __yao.agent_execution model type ExecutionRecord struct { - ID int64 `json:"id,omitempty"` // Auto-increment primary key - ExecutionID string `json:"execution_id"` // Unique execution identifier - MemberID string `json:"member_id"` // Robot member ID (globally unique) - TeamID string `json:"team_id"` // Team ID - JobID string `json:"job_id,omitempty"` // Linked job.Job ID - TriggerType types.TriggerType `json:"trigger_type"` // clock | human | event + ID int64 `json:"id,omitempty"` // Auto-increment primary key + ExecutionID string `json:"execution_id"` // Unique execution identifier + MemberID string `json:"member_id"` // Robot member ID (globally unique) + TeamID string `json:"team_id"` // Team ID + TriggerType types.TriggerType `json:"trigger_type"` // clock | human | event // Status tracking (synced with runtime Execution) Status types.ExecStatus `json:"status"` // pending | running | completed | failed | cancelled @@ -344,9 +343,6 @@ func (s *ExecutionStore) recordToMap(record *ExecutionRecord) map[string]interfa "phase": string(record.Phase), } - if record.JobID != "" { - data["job_id"] = record.JobID - } if record.Error != "" { data["error"] = record.Error } @@ -408,9 +404,6 @@ func (s *ExecutionStore) mapToRecord(row map[string]interface{}) (*ExecutionReco if v, ok := row["team_id"].(string); ok { record.TeamID = v } - if v, ok := row["job_id"].(string); ok { - record.JobID = v - } if v, ok := row["trigger_type"].(string); ok { record.TriggerType = types.TriggerType(v) } @@ -634,7 +627,6 @@ func FromExecution(exec *types.Execution) *ExecutionRecord { ExecutionID: exec.ID, MemberID: exec.MemberID, TeamID: exec.TeamID, - JobID: exec.JobID, TriggerType: exec.TriggerType, Status: exec.Status, Phase: exec.Phase, @@ -673,7 +665,6 @@ func (r *ExecutionRecord) ToExecution() *types.Execution { ID: r.ExecutionID, MemberID: r.MemberID, TeamID: r.TeamID, - JobID: r.JobID, TriggerType: r.TriggerType, Status: r.Status, Phase: r.Phase, diff --git a/agent/robot/store/execution_test.go b/agent/robot/store/execution_test.go index 39aa25be..0ded595e 100644 --- a/agent/robot/store/execution_test.go +++ b/agent/robot/store/execution_test.go @@ -35,7 +35,6 @@ func TestExecutionStoreSave(t *testing.T) { ExecutionID: "exec_test_save_001", MemberID: "member_test_001", TeamID: "team_test_001", - JobID: "job_test_001", TriggerType: types.TriggerClock, Status: types.ExecPending, Phase: types.PhaseInspiration, @@ -53,7 +52,6 @@ func TestExecutionStoreSave(t *testing.T) { assert.Equal(t, "exec_test_save_001", saved.ExecutionID) assert.Equal(t, "member_test_001", saved.MemberID) assert.Equal(t, "team_test_001", saved.TeamID) - assert.Equal(t, "job_test_001", saved.JobID) assert.Equal(t, types.TriggerClock, saved.TriggerType) assert.Equal(t, types.ExecPending, saved.Status) assert.Equal(t, types.PhaseInspiration, saved.Phase) @@ -574,7 +572,6 @@ func TestExecutionRecordConversion(t *testing.T) { ID: "exec_convert_001", MemberID: "member_convert_001", TeamID: "team_convert_001", - JobID: "job_convert_001", TriggerType: types.TriggerHuman, Status: types.ExecCompleted, Phase: types.PhaseDelivery, @@ -600,7 +597,6 @@ func TestExecutionRecordConversion(t *testing.T) { assert.Equal(t, "exec_convert_001", record.ExecutionID) assert.Equal(t, "member_convert_001", record.MemberID) assert.Equal(t, "team_convert_001", record.TeamID) - assert.Equal(t, "job_convert_001", record.JobID) assert.Equal(t, types.TriggerHuman, record.TriggerType) assert.Equal(t, types.ExecCompleted, record.Status) assert.Equal(t, types.PhaseDelivery, record.Phase) @@ -621,7 +617,6 @@ func TestExecutionRecordConversion(t *testing.T) { ExecutionID: "exec_convert_002", MemberID: "member_convert_002", TeamID: "team_convert_002", - JobID: "job_convert_002", TriggerType: types.TriggerClock, Status: types.ExecRunning, Phase: types.PhaseRun, @@ -646,7 +641,6 @@ func TestExecutionRecordConversion(t *testing.T) { assert.Equal(t, "exec_convert_002", exec.ID) assert.Equal(t, "member_convert_002", exec.MemberID) assert.Equal(t, "team_convert_002", exec.TeamID) - assert.Equal(t, "job_convert_002", exec.JobID) assert.Equal(t, types.TriggerClock, exec.TriggerType) assert.Equal(t, types.ExecRunning, exec.Status) assert.Equal(t, types.PhaseRun, exec.Phase) @@ -686,7 +680,6 @@ func setupTestExecution(t *testing.T, s *store.ExecutionStore, ctx context.Conte ExecutionID: "exec_test_get_001", MemberID: "member_test_get", TeamID: "team_test_get", - JobID: "job_test_get", TriggerType: types.TriggerClock, Status: types.ExecCompleted, Phase: types.PhaseDelivery, diff --git a/agent/robot/store/robot.go b/agent/robot/store/robot.go new file mode 100644 index 00000000..81ec3518 --- /dev/null +++ b/agent/robot/store/robot.go @@ -0,0 +1,640 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/yaoapp/gou/model" + "github.com/yaoapp/kun/maps" + "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/agent/robot/utils" +) + +// RobotRecord - persistent storage for robot member +// Maps to __yao.member model +type RobotRecord struct { + ID int64 `json:"id,omitempty"` // Auto-increment primary key + MemberID string `json:"member_id"` // Unique robot identifier + TeamID string `json:"team_id"` // Team ID + MemberType string `json:"member_type"` // Always "robot" for robots + Status string `json:"status"` // Member status: active | inactive | pending | suspended + RobotStatus string `json:"robot_status"` // Robot status: idle | working | paused | error | maintenance + AutonomousMode bool `json:"autonomous_mode"` // Whether autonomous mode is enabled + + // Profile + DisplayName string `json:"display_name"` // Display name + Bio string `json:"bio,omitempty"` // Robot description + Avatar string `json:"avatar,omitempty"` + + // Identity & Role + SystemPrompt string `json:"system_prompt"` // System prompt + RoleID string `json:"role_id"` // Role within team + ManagerID string `json:"manager_id"` // Direct manager user_id (who manages this robot) + + // Communication + RobotEmail string `json:"robot_email"` // Robot email address + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array) + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array) + + // Capabilities + RobotConfig interface{} `json:"robot_config"` // Robot config JSON + Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array) + MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array) + LanguageModel string `json:"language_model,omitempty"` // Language model name + + // Limits + CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD + + // Ownership & Audit + InvitedBy string `json:"invited_by,omitempty"` // Who created/added this robot + JoinedAt *time.Time `json:"joined_at,omitempty"` // When robot was created + + // Timestamps + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + + // Yao Permission Fields (automatically handled by Yao model when permission:true) + // These fields are passed through to the model layer for permission control + YaoCreatedBy string `json:"__yao_created_by,omitempty"` // Creator user_id (set on create) + YaoUpdatedBy string `json:"__yao_updated_by,omitempty"` // Updater user_id (set on update) + YaoTeamID string `json:"__yao_team_id,omitempty"` // Permission team scope + YaoTenantID string `json:"__yao_tenant_id,omitempty"` // Permission tenant scope +} + +// RobotListOptions - options for listing robot records +type RobotListOptions struct { + TeamID string `json:"team_id,omitempty"` + Status types.RobotStatus `json:"status,omitempty"` + Keywords string `json:"keywords,omitempty"` // Search in display_name + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"pagesize,omitempty"` + OrderBy string `json:"order_by,omitempty"` +} + +// RobotStore - persistent storage for robot members +type RobotStore struct { + modelID string +} + +// NewRobotStore creates a new robot store instance +func NewRobotStore() *RobotStore { + return &RobotStore{ + modelID: "__yao.member", + } +} + +// robotFields are the fields to select when loading robots +var robotFields = []interface{}{ + // Basic + "id", + "member_id", + "team_id", + "member_type", + "status", + "robot_status", + "autonomous_mode", + + // Profile + "display_name", + "bio", + "avatar", + + // Identity & Role + "system_prompt", + "role_id", + "manager_id", + + // Communication + "robot_email", + "authorized_senders", + "email_filter_rules", + + // Capabilities + "robot_config", + "agents", + "mcp_servers", + "language_model", + + // Limits + "cost_limit", + + // Ownership & Audit + "invited_by", + "joined_at", + + // Timestamps + "created_at", + "updated_at", + + // Yao Permission Fields (for access control) + "__yao_created_by", + "__yao_updated_by", + "__yao_team_id", + "__yao_tenant_id", +} + +// Save creates or updates a robot member record +func (s *RobotStore) Save(ctx context.Context, record *RobotRecord) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + // Ensure member_type is robot + record.MemberType = "robot" + + data := s.recordToMap(record) + + // Check if record exists by member_id + existing, err := s.Get(ctx, record.MemberID) + if err == nil && existing != nil { + // Update existing record + _, err = mod.UpdateWhere( + model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: record.MemberID}, + }, + }, + data, + ) + if err != nil { + return fmt.Errorf("failed to update robot record: %w", err) + } + return nil + } + + // Create new record + _, err = mod.Create(data) + if err != nil { + return fmt.Errorf("failed to create robot record: %w", err) + } + return nil +} + +// Get retrieves a robot record by member_id +func (s *RobotStore) Get(ctx context.Context, memberID string) (*RobotRecord, error) { + mod := model.Select(s.modelID) + if mod == nil { + return nil, fmt.Errorf("model %s not found", s.modelID) + } + + rows, err := mod.Get(model.QueryParam{ + Select: robotFields, + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: memberID}, + {Column: "member_type", Value: "robot"}, + }, + Limit: 1, + }) + if err != nil { + return nil, fmt.Errorf("failed to get robot record: %w", err) + } + + if len(rows) == 0 { + return nil, nil + } + + return s.mapToRecord(rows[0]) +} + +// List retrieves robot records with filters +func (s *RobotStore) List(ctx context.Context, opts *RobotListOptions) ([]*RobotRecord, int, error) { + mod := model.Select(s.modelID) + if mod == nil { + return nil, 0, fmt.Errorf("model %s not found", s.modelID) + } + + // Build where conditions - only require member_type=robot + wheres := []model.QueryWhere{ + {Column: "member_type", Value: "robot"}, + } + + if opts != nil { + if opts.TeamID != "" { + wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: opts.TeamID}) + } + if opts.Status != "" { + wheres = append(wheres, model.QueryWhere{Column: "robot_status", Value: string(opts.Status)}) + } + if opts.Keywords != "" { + wheres = append(wheres, model.QueryWhere{ + Column: "display_name", + OP: "like", + Value: "%" + opts.Keywords + "%", + }) + } + } + + // Build order + orders := []model.QueryOrder{} + if opts != nil && opts.OrderBy != "" { + orders = append(orders, model.QueryOrder{Column: opts.OrderBy}) + } else { + orders = append(orders, model.QueryOrder{Column: "created_at", Option: "desc"}) + } + + // Determine pagination + page := 1 + pageSize := 100 + if opts != nil { + if opts.Page > 0 { + page = opts.Page + } + if opts.PageSize > 0 { + pageSize = opts.PageSize + } + // Limit overrides PageSize for simple limit queries + if opts.Limit > 0 { + pageSize = opts.Limit + } + } + + // Execute paginated query + result, err := mod.Paginate(model.QueryParam{ + Select: robotFields, + Wheres: wheres, + Orders: orders, + }, page, pageSize) + if err != nil { + return nil, 0, fmt.Errorf("failed to list robots: %w", err) + } + + // Get total count + total := 0 + if t, ok := result.Get("total").(int); ok { + total = t + } + + // Parse records + records := []*RobotRecord{} + data := result.Get("data") + switch rows := data.(type) { + case []maps.MapStr: + for _, row := range rows { + record, err := s.mapToRecord(map[string]interface{}(row)) + if err != nil { + continue // skip invalid records + } + records = append(records, record) + } + case []map[string]interface{}: + for _, row := range rows { + record, err := s.mapToRecord(row) + if err != nil { + continue // skip invalid records + } + records = append(records, record) + } + } + + return records, total, nil +} + +// Delete removes a robot member by member_id +func (s *RobotStore) Delete(ctx context.Context, memberID string) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + _, err := mod.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: memberID}, + {Column: "member_type", Value: "robot"}, + }, + }) + if err != nil { + return fmt.Errorf("failed to delete robot record: %w", err) + } + + return nil +} + +// UpdateConfig updates only the robot_config field +func (s *RobotStore) UpdateConfig(ctx context.Context, memberID string, config interface{}) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + data := map[string]interface{}{ + "robot_config": config, + } + + _, err := mod.UpdateWhere( + model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: memberID}, + {Column: "member_type", Value: "robot"}, + }, + }, + data, + ) + if err != nil { + return fmt.Errorf("failed to update robot config: %w", err) + } + + return nil +} + +// UpdateStatus updates the robot_status field +func (s *RobotStore) UpdateStatus(ctx context.Context, memberID string, status types.RobotStatus) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + data := map[string]interface{}{ + "robot_status": string(status), + } + + _, err := mod.UpdateWhere( + model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", Value: memberID}, + {Column: "member_type", Value: "robot"}, + }, + }, + data, + ) + if err != nil { + return fmt.Errorf("failed to update robot status: %w", err) + } + + return nil +} + +// recordToMap converts RobotRecord to map for model operations +func (s *RobotStore) recordToMap(record *RobotRecord) map[string]interface{} { + data := map[string]interface{}{ + // Required fields + "member_id": record.MemberID, + "team_id": record.TeamID, + "member_type": "robot", + "autonomous_mode": record.AutonomousMode, + } + + // Status + if record.Status != "" { + data["status"] = record.Status + } else { + data["status"] = "active" + } + if record.RobotStatus != "" { + data["robot_status"] = record.RobotStatus + } else { + data["robot_status"] = "idle" + } + + // Profile + if record.DisplayName != "" { + data["display_name"] = record.DisplayName + } + if record.Bio != "" { + data["bio"] = record.Bio + } + if record.Avatar != "" { + data["avatar"] = record.Avatar + } + + // Identity & Role + if record.SystemPrompt != "" { + data["system_prompt"] = record.SystemPrompt + } + if record.RoleID != "" { + data["role_id"] = record.RoleID + } + if record.ManagerID != "" { + data["manager_id"] = record.ManagerID + } + + // Communication + if record.RobotEmail != "" { + data["robot_email"] = record.RobotEmail + } + if record.AuthorizedSenders != nil { + data["authorized_senders"] = record.AuthorizedSenders + } + if record.EmailFilterRules != nil { + data["email_filter_rules"] = record.EmailFilterRules + } + + // Capabilities + if record.RobotConfig != nil { + data["robot_config"] = record.RobotConfig + } + if record.Agents != nil { + data["agents"] = record.Agents + } + if record.MCPServers != nil { + data["mcp_servers"] = record.MCPServers + } + if record.LanguageModel != "" { + data["language_model"] = record.LanguageModel + } + + // Limits + if record.CostLimit > 0 { + data["cost_limit"] = record.CostLimit + } + + // Ownership & Audit + if record.InvitedBy != "" { + data["invited_by"] = record.InvitedBy + } + if record.JoinedAt != nil { + // Format time for Gou model (expects string format) + data["joined_at"] = record.JoinedAt.Format("2006-01-02 15:04:05") + } + + // Yao Permission Fields - pass through for model layer + if record.YaoCreatedBy != "" { + data["__yao_created_by"] = record.YaoCreatedBy + } + if record.YaoUpdatedBy != "" { + data["__yao_updated_by"] = record.YaoUpdatedBy + } + if record.YaoTeamID != "" { + data["__yao_team_id"] = record.YaoTeamID + } + if record.YaoTenantID != "" { + data["__yao_tenant_id"] = record.YaoTenantID + } + + return data +} + +// mapToRecord converts a model row to RobotRecord +func (s *RobotStore) mapToRecord(row map[string]interface{}) (*RobotRecord, error) { + record := &RobotRecord{} + + // Basic fields + if v, ok := row["id"]; ok { + switch id := v.(type) { + case float64: + record.ID = int64(id) + case int64: + record.ID = id + case int: + record.ID = int64(id) + } + } + if v, ok := row["member_id"].(string); ok { + record.MemberID = v + } + if v, ok := row["team_id"].(string); ok { + record.TeamID = v + } + if v, ok := row["member_type"].(string); ok { + record.MemberType = v + } + if v, ok := row["status"].(string); ok { + record.Status = v + } + if v, ok := row["robot_status"].(string); ok { + record.RobotStatus = v + } + if v, ok := row["autonomous_mode"]; ok { + record.AutonomousMode = utils.ToBool(v) + } + + // Profile + if v, ok := row["display_name"].(string); ok { + record.DisplayName = v + } + if v, ok := row["bio"].(string); ok { + record.Bio = v + } + if v, ok := row["avatar"].(string); ok { + record.Avatar = v + } + + // Identity & Role + if v, ok := row["system_prompt"].(string); ok { + record.SystemPrompt = v + } + if v, ok := row["role_id"].(string); ok { + record.RoleID = v + } + if v, ok := row["manager_id"].(string); ok { + record.ManagerID = v + } + + // Communication + if v, ok := row["robot_email"].(string); ok { + record.RobotEmail = v + } + if v := row["authorized_senders"]; v != nil { + record.AuthorizedSenders = utils.ToJSONValue(v) + } + if v := row["email_filter_rules"]; v != nil { + record.EmailFilterRules = utils.ToJSONValue(v) + } + + // Capabilities + if v := row["robot_config"]; v != nil { + record.RobotConfig = utils.ToJSONValue(v) + } + if v := row["agents"]; v != nil { + record.Agents = utils.ToJSONValue(v) + } + if v := row["mcp_servers"]; v != nil { + record.MCPServers = utils.ToJSONValue(v) + } + if v, ok := row["language_model"].(string); ok { + record.LanguageModel = v + } + + // Limits + if v := row["cost_limit"]; v != nil { + record.CostLimit = utils.ToFloat64(v) + } + + // Ownership & Audit + if v, ok := row["invited_by"].(string); ok { + record.InvitedBy = v + } + if v := row["joined_at"]; v != nil { + record.JoinedAt = utils.ToTimestamp(v) + } + + // Timestamps + if v := row["created_at"]; v != nil { + record.CreatedAt = utils.ToTimestamp(v) + } + if v := row["updated_at"]; v != nil { + record.UpdatedAt = utils.ToTimestamp(v) + } + + // Yao Permission Fields + if v, ok := row["__yao_created_by"].(string); ok { + record.YaoCreatedBy = v + } + if v, ok := row["__yao_updated_by"].(string); ok { + record.YaoUpdatedBy = v + } + if v, ok := row["__yao_team_id"].(string); ok { + record.YaoTeamID = v + } + if v, ok := row["__yao_tenant_id"].(string); ok { + record.YaoTenantID = v + } + + return record, nil +} + +// ToRobot converts a RobotRecord to types.Robot +func (r *RobotRecord) ToRobot() (*types.Robot, error) { + robot := &types.Robot{ + MemberID: r.MemberID, + TeamID: r.TeamID, + DisplayName: r.DisplayName, + Bio: r.Bio, + SystemPrompt: r.SystemPrompt, + AutonomousMode: r.AutonomousMode, + RobotEmail: r.RobotEmail, + } + + // Parse robot_status + if r.RobotStatus != "" { + robot.Status = types.RobotStatus(r.RobotStatus) + } else { + robot.Status = types.RobotIdle + } + + // Parse robot_config + if r.RobotConfig != nil { + config, err := types.ParseConfig(r.RobotConfig) + if err != nil { + return nil, fmt.Errorf("failed to parse robot_config: %w", err) + } + robot.Config = config + } + + return robot, nil +} + +// FromRobot creates a RobotRecord from types.Robot +func FromRobot(robot *types.Robot) *RobotRecord { + record := &RobotRecord{ + MemberID: robot.MemberID, + TeamID: robot.TeamID, + DisplayName: robot.DisplayName, + Bio: robot.Bio, + SystemPrompt: robot.SystemPrompt, + RobotStatus: string(robot.Status), + AutonomousMode: robot.AutonomousMode, + RobotEmail: robot.RobotEmail, + MemberType: "robot", + Status: "active", + } + + if robot.Config != nil { + record.RobotConfig = robot.Config + } + + return record +} diff --git a/agent/robot/store/robot_test.go b/agent/robot/store/robot_test.go new file mode 100644 index 00000000..0c392861 --- /dev/null +++ b/agent/robot/store/robot_test.go @@ -0,0 +1,578 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/gou/model" + "github.com/yaoapp/yao/agent/robot/store" + "github.com/yaoapp/yao/agent/testutils" +) + +// TestRobotStoreSave tests creating and updating robot records +func TestRobotStoreSave(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + t.Run("creates_new_robot_record", func(t *testing.T) { + now := time.Now() + record := &store.RobotRecord{ + MemberID: "robot_test_save_001", + TeamID: "team_test_001", + DisplayName: "Test Robot 001", + Bio: "A test robot for save operations", + SystemPrompt: "You are a helpful assistant", + Status: "active", + RobotStatus: "idle", + AutonomousMode: true, + RobotEmail: "robot001@test.com", + JoinedAt: &now, + } + + err := s.Save(ctx, record) + require.NoError(t, err) + + // Verify it was created + saved, err := s.Get(ctx, "robot_test_save_001") + require.NoError(t, err) + require.NotNil(t, saved) + + assert.Equal(t, "robot_test_save_001", saved.MemberID) + assert.Equal(t, "team_test_001", saved.TeamID) + assert.Equal(t, "Test Robot 001", saved.DisplayName) + assert.Equal(t, "A test robot for save operations", saved.Bio) + assert.Equal(t, "You are a helpful assistant", saved.SystemPrompt) + assert.Equal(t, "active", saved.Status) + assert.Equal(t, "idle", saved.RobotStatus) + assert.True(t, saved.AutonomousMode) + assert.Equal(t, "robot001@test.com", saved.RobotEmail) + assert.Equal(t, "robot", saved.MemberType) + assert.NotNil(t, saved.JoinedAt) + }) + + t.Run("updates_existing_robot_record", func(t *testing.T) { + // First create a record + record := &store.RobotRecord{ + MemberID: "robot_test_save_002", + TeamID: "team_test_002", + DisplayName: "Original Name", + Status: "active", + RobotStatus: "idle", + } + + err := s.Save(ctx, record) + require.NoError(t, err) + + // Update the record + record.DisplayName = "Updated Name" + record.Bio = "Updated bio" + record.RobotStatus = "working" + + err = s.Save(ctx, record) + require.NoError(t, err) + + // Verify the update + saved, err := s.Get(ctx, "robot_test_save_002") + require.NoError(t, err) + require.NotNil(t, saved) + + assert.Equal(t, "Updated Name", saved.DisplayName) + assert.Equal(t, "Updated bio", saved.Bio) + assert.Equal(t, "working", saved.RobotStatus) + }) + + t.Run("saves_robot_with_config", func(t *testing.T) { + record := &store.RobotRecord{ + MemberID: "robot_test_save_003", + TeamID: "team_test_003", + DisplayName: "Robot with Config", + Status: "active", + RobotStatus: "idle", + RobotConfig: map[string]interface{}{ + "clock_mode": "on", + "max_concurrent": 3, + "timeout_seconds": 300, + }, + } + + err := s.Save(ctx, record) + require.NoError(t, err) + + saved, err := s.Get(ctx, "robot_test_save_003") + require.NoError(t, err) + require.NotNil(t, saved) + assert.NotNil(t, saved.RobotConfig) + }) + + t.Run("saves_robot_with_permission_fields", func(t *testing.T) { + record := &store.RobotRecord{ + MemberID: "robot_test_save_004", + TeamID: "team_test_004", + DisplayName: "Robot with Perms", + Status: "active", + RobotStatus: "idle", + YaoCreatedBy: "user_001", + YaoTeamID: "team_001", + YaoTenantID: "tenant_001", + } + + err := s.Save(ctx, record) + require.NoError(t, err) + + // Yao permission fields are handled by the model layer + saved, err := s.Get(ctx, "robot_test_save_004") + require.NoError(t, err) + require.NotNil(t, saved) + }) +} + +// TestRobotStoreGet tests retrieving robot records +func TestRobotStoreGet(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + // Create a test record + setupTestRobot(t, s, ctx) + + t.Run("returns_existing_record", func(t *testing.T) { + record, err := s.Get(ctx, "robot_test_get_001") + require.NoError(t, err) + require.NotNil(t, record) + + assert.Equal(t, "robot_test_get_001", record.MemberID) + assert.Equal(t, "team_test_get", record.TeamID) + assert.Equal(t, "Test Robot Get", record.DisplayName) + assert.Equal(t, "Test robot description", record.Bio) + assert.Equal(t, "robot", record.MemberType) + assert.Equal(t, "active", record.Status) + assert.Equal(t, "idle", record.RobotStatus) + }) + + t.Run("returns_nil_for_non_existent_record", func(t *testing.T) { + record, err := s.Get(ctx, "robot_non_existent") + require.NoError(t, err) + assert.Nil(t, record) + }) + + t.Run("ignores_non_robot_members", func(t *testing.T) { + // Get should only return member_type="robot" records + record, err := s.Get(ctx, "robot_test_get_001") + require.NoError(t, err) + require.NotNil(t, record) + assert.Equal(t, "robot", record.MemberType) + }) +} + +// TestRobotStoreList tests listing robot records with filters +func TestRobotStoreList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + // Create multiple test records + setupTestRobotsForList(t, s, ctx) + + t.Run("lists_all_robot_records", func(t *testing.T) { + // List with keywords filter to only get our test records + // Test robots have display names like "Robot Alpha", "Robot Beta", etc. + records, total, err := s.List(ctx, &store.RobotListOptions{ + Keywords: "Robot", + }) + require.NoError(t, err) + // Should find at least our 4 test robots + assert.GreaterOrEqual(t, len(records), 4) + assert.GreaterOrEqual(t, total, 4) + }) + + t.Run("filters_by_team_id", func(t *testing.T) { + records, total, err := s.List(ctx, &store.RobotListOptions{ + TeamID: "team_list_001", + }) + require.NoError(t, err) + assert.Equal(t, 2, len(records)) + assert.Equal(t, 2, total) + for _, r := range records { + assert.Equal(t, "team_list_001", r.TeamID) + } + }) + + t.Run("filters_by_robot_status", func(t *testing.T) { + records, _, err := s.List(ctx, &store.RobotListOptions{ + Status: "working", + }) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(records), 1) + for _, r := range records { + assert.Equal(t, "working", r.RobotStatus) + } + }) + + t.Run("filters_by_keywords", func(t *testing.T) { + records, _, err := s.List(ctx, &store.RobotListOptions{ + Keywords: "Alpha", + }) + require.NoError(t, err) + assert.Equal(t, 1, len(records)) + assert.Contains(t, records[0].DisplayName, "Alpha") + }) + + t.Run("respects_pagination", func(t *testing.T) { + records, total, err := s.List(ctx, &store.RobotListOptions{ + Page: 1, + PageSize: 2, + }) + require.NoError(t, err) + assert.Equal(t, 2, len(records)) + assert.GreaterOrEqual(t, total, 4) // total count should be full count + }) + + t.Run("respects_limit", func(t *testing.T) { + records, _, err := s.List(ctx, &store.RobotListOptions{ + Limit: 2, + }) + require.NoError(t, err) + assert.Equal(t, 2, len(records)) + }) + + t.Run("combines_multiple_filters", func(t *testing.T) { + records, total, err := s.List(ctx, &store.RobotListOptions{ + TeamID: "team_list_001", + Status: "idle", + }) + require.NoError(t, err) + assert.Equal(t, 1, len(records)) + assert.Equal(t, 1, total) + assert.Equal(t, "team_list_001", records[0].TeamID) + assert.Equal(t, "idle", records[0].RobotStatus) + }) +} + +// TestRobotStoreDelete tests deleting robot records +func TestRobotStoreDelete(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + t.Run("deletes_existing_record", func(t *testing.T) { + // Create a record + record := &store.RobotRecord{ + MemberID: "robot_test_delete_001", + TeamID: "team_delete_001", + DisplayName: "Robot to Delete", + Status: "active", + RobotStatus: "idle", + } + err := s.Save(ctx, record) + require.NoError(t, err) + + // Verify it exists + saved, err := s.Get(ctx, "robot_test_delete_001") + require.NoError(t, err) + require.NotNil(t, saved) + + // Delete it + err = s.Delete(ctx, "robot_test_delete_001") + require.NoError(t, err) + + // Verify it's gone + saved, err = s.Get(ctx, "robot_test_delete_001") + require.NoError(t, err) + assert.Nil(t, saved) + }) + + t.Run("no_error_for_non_existent_record", func(t *testing.T) { + err := s.Delete(ctx, "robot_non_existent") + assert.NoError(t, err) + }) +} + +// TestRobotStoreUpdateConfig tests updating robot config +func TestRobotStoreUpdateConfig(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + // Create a base record + record := &store.RobotRecord{ + MemberID: "robot_test_config_001", + TeamID: "team_config_001", + DisplayName: "Config Test Robot", + Status: "active", + RobotStatus: "idle", + RobotConfig: map[string]interface{}{ + "clock_mode": "off", + }, + } + err := s.Save(ctx, record) + require.NoError(t, err) + + t.Run("updates_config_only", func(t *testing.T) { + newConfig := map[string]interface{}{ + "clock_mode": "on", + "max_concurrent": 5, + "timeout_seconds": 600, + } + err := s.UpdateConfig(ctx, "robot_test_config_001", newConfig) + require.NoError(t, err) + + saved, err := s.Get(ctx, "robot_test_config_001") + require.NoError(t, err) + require.NotNil(t, saved) + assert.NotNil(t, saved.RobotConfig) + + // Display name should be unchanged + assert.Equal(t, "Config Test Robot", saved.DisplayName) + }) +} + +// TestRobotStoreUpdateStatus tests updating robot status +func TestRobotStoreUpdateStatus(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestRobots(t) + defer cleanupTestRobots(t) + + s := store.NewRobotStore() + ctx := context.Background() + + // Create a base record + record := &store.RobotRecord{ + MemberID: "robot_test_status_001", + TeamID: "team_status_001", + DisplayName: "Status Test Robot", + Status: "active", + RobotStatus: "idle", + } + err := s.Save(ctx, record) + require.NoError(t, err) + + t.Run("updates_robot_status", func(t *testing.T) { + err := s.UpdateStatus(ctx, "robot_test_status_001", "working") + require.NoError(t, err) + + saved, err := s.Get(ctx, "robot_test_status_001") + require.NoError(t, err) + require.NotNil(t, saved) + assert.Equal(t, "working", saved.RobotStatus) + // Display name should be unchanged + assert.Equal(t, "Status Test Robot", saved.DisplayName) + }) + + t.Run("updates_to_paused", func(t *testing.T) { + err := s.UpdateStatus(ctx, "robot_test_status_001", "paused") + require.NoError(t, err) + + saved, err := s.Get(ctx, "robot_test_status_001") + require.NoError(t, err) + assert.Equal(t, "paused", saved.RobotStatus) + }) + + t.Run("updates_to_error", func(t *testing.T) { + err := s.UpdateStatus(ctx, "robot_test_status_001", "error") + require.NoError(t, err) + + saved, err := s.Get(ctx, "robot_test_status_001") + require.NoError(t, err) + assert.Equal(t, "error", saved.RobotStatus) + }) +} + +// TestRobotRecordConversion tests conversion between RobotRecord and Robot types +func TestRobotRecordConversion(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + t.Run("converts_record_to_robot", func(t *testing.T) { + now := time.Now() + record := &store.RobotRecord{ + MemberID: "robot_convert_001", + TeamID: "team_convert_001", + DisplayName: "Conversion Test Robot", + Bio: "Test description", + SystemPrompt: "You are helpful", + Status: "active", + RobotStatus: "idle", + AutonomousMode: true, + RobotEmail: "convert@test.com", + JoinedAt: &now, + RobotConfig: map[string]interface{}{ + "clock_mode": "on", + }, + } + + robot, err := record.ToRobot() + require.NoError(t, err) + require.NotNil(t, robot) + + assert.Equal(t, "robot_convert_001", robot.MemberID) + assert.Equal(t, "team_convert_001", robot.TeamID) + assert.Equal(t, "Conversion Test Robot", robot.DisplayName) + assert.Equal(t, "Test description", robot.Bio) + assert.Equal(t, "You are helpful", robot.SystemPrompt) + assert.True(t, robot.AutonomousMode) + assert.Equal(t, "convert@test.com", robot.RobotEmail) + }) + + t.Run("converts_robot_to_record", func(t *testing.T) { + robot := &store.RobotRecord{ + MemberID: "robot_from_001", + TeamID: "team_from_001", + DisplayName: "From Robot Test", + Bio: "From robot description", + SystemPrompt: "System prompt", + RobotStatus: "working", + AutonomousMode: false, + RobotEmail: "from@test.com", + } + + // ToRobot and verify + converted, err := robot.ToRobot() + require.NoError(t, err) + + assert.Equal(t, "robot_from_001", converted.MemberID) + assert.Equal(t, "team_from_001", converted.TeamID) + assert.Equal(t, "From Robot Test", converted.DisplayName) + }) +} + +// Helper functions + +func cleanupTestRobots(t *testing.T) { + mod := model.Select("__yao.member") + if mod == nil { + return + } + + // Delete all test robot records + _, err := mod.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "member_id", OP: "like", Value: "robot_test_%"}, + {Column: "member_type", Value: "robot"}, + }, + }) + if err != nil { + t.Logf("Warning: failed to cleanup test robots: %v", err) + } +} + +func setupTestRobot(t *testing.T, s *store.RobotStore, ctx context.Context) { + now := time.Now() + record := &store.RobotRecord{ + MemberID: "robot_test_get_001", + TeamID: "team_test_get", + DisplayName: "Test Robot Get", + Bio: "Test robot description", + SystemPrompt: "You are a test assistant", + Status: "active", + RobotStatus: "idle", + AutonomousMode: false, + RobotEmail: "test@robot.com", + JoinedAt: &now, + } + + err := s.Save(ctx, record) + require.NoError(t, err) +} + +func setupTestRobotsForList(t *testing.T, s *store.RobotStore, ctx context.Context) { + now := time.Now() + + records := []*store.RobotRecord{ + { + MemberID: "robot_test_list_001", + TeamID: "team_list_001", + DisplayName: "Robot Alpha", + Status: "active", + RobotStatus: "idle", + JoinedAt: &now, + }, + { + MemberID: "robot_test_list_002", + TeamID: "team_list_001", + DisplayName: "Robot Beta", + Status: "active", + RobotStatus: "working", + JoinedAt: &now, + }, + { + MemberID: "robot_test_list_003", + TeamID: "team_list_002", + DisplayName: "Robot Gamma", + Status: "active", + RobotStatus: "idle", + JoinedAt: &now, + }, + { + MemberID: "robot_test_list_004", + TeamID: "team_list_002", + DisplayName: "Robot Delta", + Status: "inactive", + RobotStatus: "paused", + JoinedAt: &now, + }, + } + + for _, record := range records { + err := s.Save(ctx, record) + require.NoError(t, err) + } +} diff --git a/agent/robot/types/robot.go b/agent/robot/types/robot.go index d84b94bb..1dfea09a 100644 --- a/agent/robot/types/robot.go +++ b/agent/robot/types/robot.go @@ -11,12 +11,13 @@ import ( // Robot - runtime representation of an autonomous robot (from __yao.member) // Relationship: 1 Robot : N Executions (concurrent) -// Each trigger creates a new Execution (mapped to job.Job) +// Each trigger creates a new Execution (stored in __yao.agent_execution) type Robot struct { // From __yao.member MemberID string `json:"member_id"` TeamID string `json:"team_id"` DisplayName string `json:"display_name"` + Bio string `json:"bio"` // Robot's description (from __yao.member.bio) SystemPrompt string `json:"system_prompt"` Status RobotStatus `json:"robot_status"` AutonomousMode bool `json:"autonomous_mode"` @@ -116,8 +117,7 @@ func (r *Robot) GetExecutions() []*Execution { } // Execution - single execution instance -// Each trigger creates a new Execution, mapped to a job.Job for monitoring -// Relationship: 1 Execution = 1 job.Job +// Each trigger creates a new Execution, stored in ExecutionStore type Execution struct { ID string `json:"id"` // unique execution ID MemberID string `json:"member_id"` // robot member ID @@ -129,9 +129,6 @@ type Execution struct { Phase Phase `json:"phase"` Error string `json:"error,omitempty"` - // Job integration (each Execution = 1 job.Job) - JobID string `json:"job_id"` // corresponding job.Job ID - // Trigger input (stored for traceability) Input *TriggerInput `json:"input,omitempty"` // original trigger input @@ -381,6 +378,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) { MemberID: memberID, TeamID: teamID, DisplayName: getString(m, "display_name"), + Bio: getString(m, "bio"), SystemPrompt: getString(m, "system_prompt"), AutonomousMode: getBool(m, "autonomous_mode"), RobotEmail: getString(m, "robot_email"), diff --git a/agent/robot/types/robot_test.go b/agent/robot/types/robot_test.go index dad84d12..dcd24bc5 100644 --- a/agent/robot/types/robot_test.go +++ b/agent/robot/types/robot_test.go @@ -362,7 +362,6 @@ func TestExecutionStructure(t *testing.T) { TriggerType: types.TriggerClock, Status: types.ExecRunning, Phase: types.PhaseGoals, - JobID: "job1", } assert.Equal(t, "exec1", exec.ID) @@ -371,7 +370,6 @@ func TestExecutionStructure(t *testing.T) { assert.Equal(t, types.TriggerClock, exec.TriggerType) assert.Equal(t, types.ExecRunning, exec.Status) assert.Equal(t, types.PhaseGoals, exec.Phase) - assert.Equal(t, "job1", exec.JobID) }) t.Run("execution with trigger input", func(t *testing.T) { diff --git a/agent/robot/utils/convert.go b/agent/robot/utils/convert.go index aec43c25..14ed8de0 100644 --- a/agent/robot/utils/convert.go +++ b/agent/robot/utils/convert.go @@ -3,8 +3,384 @@ package utils import ( "encoding/json" "fmt" + "time" ) +// ==================== To Functions ==================== +// Convert any value to specified type (safe, returns zero value on failure) + +// ToString converts any value to string +func ToString(v interface{}) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case []byte: + return string(val) + case int: + return fmt.Sprintf("%d", val) + case int8: + return fmt.Sprintf("%d", val) + case int16: + return fmt.Sprintf("%d", val) + case int32: + return fmt.Sprintf("%d", val) + case int64: + return fmt.Sprintf("%d", val) + case uint: + return fmt.Sprintf("%d", val) + case uint8: + return fmt.Sprintf("%d", val) + case uint16: + return fmt.Sprintf("%d", val) + case uint32: + return fmt.Sprintf("%d", val) + case uint64: + return fmt.Sprintf("%d", val) + case float32: + return fmt.Sprintf("%g", val) + case float64: + return fmt.Sprintf("%g", val) + case bool: + if val { + return "true" + } + return "false" + default: + if str, err := json.Marshal(v); err == nil { + return string(str) + } + return fmt.Sprintf("%v", v) + } +} + +// ToBool converts any value to bool +func ToBool(v interface{}) bool { + if v == nil { + return false + } + switch b := v.(type) { + case bool: + return b + case int: + return b != 0 + case int8: + return b != 0 + case int16: + return b != 0 + case int32: + return b != 0 + case int64: + return b != 0 + case uint: + return b != 0 + case uint8: + return b != 0 + case uint16: + return b != 0 + case uint32: + return b != 0 + case uint64: + return b != 0 + case float32: + return b != 0 + case float64: + return b != 0 + case string: + return b == "true" || b == "1" || b == "yes" || b == "on" + } + return false +} + +// ToInt converts any value to int +func ToInt(v interface{}) int { + if v == nil { + return 0 + } + switch n := v.(type) { + case int: + return n + case int8: + return int(n) + case int16: + return int(n) + case int32: + return int(n) + case int64: + return int(n) + case uint: + return int(n) + case uint8: + return int(n) + case uint16: + return int(n) + case uint32: + return int(n) + case uint64: + return int(n) + case float32: + return int(n) + case float64: + return int(n) + case string: + var i int + fmt.Sscanf(n, "%d", &i) + return i + case bool: + if n { + return 1 + } + return 0 + } + return 0 +} + +// ToInt64 converts any value to int64 +func ToInt64(v interface{}) int64 { + if v == nil { + return 0 + } + switch n := v.(type) { + case int64: + return n + case int: + return int64(n) + case int8: + return int64(n) + case int16: + return int64(n) + case int32: + return int64(n) + case uint: + return int64(n) + case uint8: + return int64(n) + case uint16: + return int64(n) + case uint32: + return int64(n) + case uint64: + return int64(n) + case float32: + return int64(n) + case float64: + return int64(n) + case string: + var i int64 + fmt.Sscanf(n, "%d", &i) + return i + case bool: + if n { + return 1 + } + return 0 + } + return 0 +} + +// ToFloat64 converts any value to float64 +func ToFloat64(v interface{}) float64 { + if v == nil { + return 0 + } + switch f := v.(type) { + case float64: + return f + case float32: + return float64(f) + case int: + return float64(f) + case int8: + return float64(f) + case int16: + return float64(f) + case int32: + return float64(f) + case int64: + return float64(f) + case uint: + return float64(f) + case uint8: + return float64(f) + case uint16: + return float64(f) + case uint32: + return float64(f) + case uint64: + return float64(f) + case string: + var result float64 + fmt.Sscanf(f, "%f", &result) + return result + case bool: + if f { + return 1 + } + return 0 + } + return 0 +} + +// ToTimestamp converts any value to *time.Time +// Handles: time.Time, *time.Time, string (various formats), int64/float64 (unix timestamp) +func ToTimestamp(v interface{}) *time.Time { + if v == nil { + return nil + } + switch t := v.(type) { + case time.Time: + return &t + case *time.Time: + return t + case string: + if t == "" { + return nil + } + // Try common time formats + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05", + "2006-01-02", + } + for _, format := range formats { + if parsed, err := time.Parse(format, t); err == nil { + return &parsed + } + } + case int64: + // Unix timestamp (seconds) + parsed := time.Unix(t, 0) + return &parsed + case int: + parsed := time.Unix(int64(t), 0) + return &parsed + case float64: + // Unix timestamp (seconds as float) + parsed := time.Unix(int64(t), 0) + return &parsed + } + return nil +} + +// ToJSONValue parses JSON from string/[]byte or returns already-parsed value +func ToJSONValue(v interface{}) interface{} { + if v == nil { + return nil + } + switch data := v.(type) { + case string: + if data == "" { + return nil + } + var result interface{} + if err := json.Unmarshal([]byte(data), &result); err != nil { + return nil + } + return result + case []byte: + if len(data) == 0 { + return nil + } + var result interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + return result + case map[string]interface{}, []interface{}: + // Already parsed + return data + default: + return v + } +} + +// ==================== Get Functions ==================== +// Safely get typed value from map[string]interface{} + +// GetString safely gets a string value from map +func GetString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + if v, ok := m[key]; ok { + return ToString(v) + } + return "" +} + +// GetBool safely gets a bool value from map +func GetBool(m map[string]interface{}, key string) bool { + if m == nil { + return false + } + if v, ok := m[key]; ok { + return ToBool(v) + } + return false +} + +// GetInt safely gets an int value from map +func GetInt(m map[string]interface{}, key string) int { + if m == nil { + return 0 + } + if v, ok := m[key]; ok { + return ToInt(v) + } + return 0 +} + +// GetInt64 safely gets an int64 value from map +func GetInt64(m map[string]interface{}, key string) int64 { + if m == nil { + return 0 + } + if v, ok := m[key]; ok { + return ToInt64(v) + } + return 0 +} + +// GetFloat64 safely gets a float64 value from map +func GetFloat64(m map[string]interface{}, key string) float64 { + if m == nil { + return 0 + } + if v, ok := m[key]; ok { + return ToFloat64(v) + } + return 0 +} + +// GetTimestamp safely gets a *time.Time value from map +func GetTimestamp(m map[string]interface{}, key string) *time.Time { + if m == nil { + return nil + } + if v, ok := m[key]; ok { + return ToTimestamp(v) + } + return nil +} + +// GetJSONValue safely gets a parsed JSON value from map +func GetJSONValue(m map[string]interface{}, key string) interface{} { + if m == nil { + return nil + } + if v, ok := m[key]; ok { + return ToJSONValue(v) + } + return nil +} + +// ==================== JSON/Map Conversion ==================== + // ToJSON converts any value to JSON string func ToJSON(v interface{}) (string, error) { data, err := json.Marshal(v) @@ -14,7 +390,7 @@ func ToJSON(v interface{}) (string, error) { return string(data), nil } -// FromJSON parses JSON string to target +// FromJSON parses JSON string to target struct func FromJSON(jsonStr string, target interface{}) error { return json.Unmarshal([]byte(jsonStr), target) } @@ -25,12 +401,10 @@ func ToMap(v interface{}) (map[string]interface{}, error) { if err != nil { return nil, err } - var result map[string]interface{} if err := json.Unmarshal(data, &result); err != nil { return nil, err } - return result, nil } @@ -43,29 +417,7 @@ func FromMap(m map[string]interface{}, target interface{}) error { return json.Unmarshal(data, target) } -// ToString converts any value to string -func ToString(v interface{}) string { - switch val := v.(type) { - case string: - return val - case []byte: - return string(val) - case int, int8, int16, int32, int64: - return fmt.Sprintf("%d", val) - case uint, uint8, uint16, uint32, uint64: - return fmt.Sprintf("%d", val) - case float32, float64: - return fmt.Sprintf("%f", val) - case bool: - return fmt.Sprintf("%t", val) - default: - // Fallback to JSON - if str, err := ToJSON(v); err == nil { - return str - } - return fmt.Sprintf("%v", v) - } -} +// ==================== Map Utilities ==================== // MergeMap merges source map into target map (shallow copy) func MergeMap(target, source map[string]interface{}) map[string]interface{} { @@ -89,58 +441,3 @@ func CloneMap(m map[string]interface{}) map[string]interface{} { } return result } - -// GetString safely gets a string value from map -func GetString(m map[string]interface{}, key string) string { - if m == nil { - return "" - } - if v, ok := m[key]; ok && v != nil { - return ToString(v) - } - return "" -} - -// GetBool safely gets a bool value from map -func GetBool(m map[string]interface{}, key string) bool { - if m == nil { - return false - } - if v, ok := m[key]; ok && v != nil { - switch b := v.(type) { - case bool: - return b - case int: - return b != 0 - case int64: - return b != 0 - case float64: - return b != 0 - case string: - return b == "true" || b == "1" - } - } - return false -} - -// GetInt safely gets an int value from map -func GetInt(m map[string]interface{}, key string) int { - if m == nil { - return 0 - } - if v, ok := m[key]; ok && v != nil { - switch n := v.(type) { - case int: - return n - case int64: - return int(n) - case float64: - return int(n) - case string: - var i int - fmt.Sscanf(n, "%d", &i) - return i - } - } - return 0 -} diff --git a/agent/robot/utils/convert_test.go b/agent/robot/utils/convert_test.go new file mode 100644 index 00000000..691740d0 --- /dev/null +++ b/agent/robot/utils/convert_test.go @@ -0,0 +1,567 @@ +package utils_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/agent/robot/utils" +) + +// ==================== To Tests ==================== + +func TestToBool(t *testing.T) { + t.Run("from_bool", func(t *testing.T) { + assert.True(t, utils.ToBool(true)) + assert.False(t, utils.ToBool(false)) + }) + + t.Run("from_int", func(t *testing.T) { + assert.True(t, utils.ToBool(1)) + assert.True(t, utils.ToBool(42)) + assert.False(t, utils.ToBool(0)) + }) + + t.Run("from_int64", func(t *testing.T) { + assert.True(t, utils.ToBool(int64(1))) + assert.False(t, utils.ToBool(int64(0))) + }) + + t.Run("from_float64", func(t *testing.T) { + assert.True(t, utils.ToBool(1.0)) + assert.True(t, utils.ToBool(0.1)) + assert.False(t, utils.ToBool(0.0)) + }) + + t.Run("from_string", func(t *testing.T) { + assert.True(t, utils.ToBool("true")) + assert.True(t, utils.ToBool("1")) + assert.True(t, utils.ToBool("yes")) + assert.True(t, utils.ToBool("on")) + assert.False(t, utils.ToBool("false")) + assert.False(t, utils.ToBool("0")) + assert.False(t, utils.ToBool("")) + }) + + t.Run("from_nil", func(t *testing.T) { + assert.False(t, utils.ToBool(nil)) + }) + + t.Run("from_unsupported_type", func(t *testing.T) { + assert.False(t, utils.ToBool([]int{1, 2, 3})) + }) +} + +func TestToInt(t *testing.T) { + t.Run("from_int", func(t *testing.T) { + assert.Equal(t, 42, utils.ToInt(42)) + assert.Equal(t, -10, utils.ToInt(-10)) + }) + + t.Run("from_int64", func(t *testing.T) { + assert.Equal(t, 100, utils.ToInt(int64(100))) + }) + + t.Run("from_float64", func(t *testing.T) { + assert.Equal(t, 42, utils.ToInt(42.9)) // truncates + assert.Equal(t, -5, utils.ToInt(-5.7)) + }) + + t.Run("from_string", func(t *testing.T) { + assert.Equal(t, 123, utils.ToInt("123")) + assert.Equal(t, -456, utils.ToInt("-456")) + assert.Equal(t, 0, utils.ToInt("invalid")) + }) + + t.Run("from_bool", func(t *testing.T) { + assert.Equal(t, 1, utils.ToInt(true)) + assert.Equal(t, 0, utils.ToInt(false)) + }) + + t.Run("from_nil", func(t *testing.T) { + assert.Equal(t, 0, utils.ToInt(nil)) + }) +} + +func TestToInt64(t *testing.T) { + t.Run("from_int64", func(t *testing.T) { + assert.Equal(t, int64(9223372036854775807), utils.ToInt64(int64(9223372036854775807))) + }) + + t.Run("from_int", func(t *testing.T) { + assert.Equal(t, int64(42), utils.ToInt64(42)) + }) + + t.Run("from_float64", func(t *testing.T) { + assert.Equal(t, int64(42), utils.ToInt64(42.9)) + }) + + t.Run("from_string", func(t *testing.T) { + assert.Equal(t, int64(123456789), utils.ToInt64("123456789")) + }) + + t.Run("from_nil", func(t *testing.T) { + assert.Equal(t, int64(0), utils.ToInt64(nil)) + }) +} + +func TestToFloat64(t *testing.T) { + t.Run("from_float64", func(t *testing.T) { + assert.Equal(t, 3.14159, utils.ToFloat64(3.14159)) + }) + + t.Run("from_float32", func(t *testing.T) { + assert.InDelta(t, 3.14, utils.ToFloat64(float32(3.14)), 0.001) + }) + + t.Run("from_int", func(t *testing.T) { + assert.Equal(t, 42.0, utils.ToFloat64(42)) + }) + + t.Run("from_int64", func(t *testing.T) { + assert.Equal(t, 100.0, utils.ToFloat64(int64(100))) + }) + + t.Run("from_string", func(t *testing.T) { + assert.InDelta(t, 3.14, utils.ToFloat64("3.14"), 0.001) + assert.Equal(t, 0.0, utils.ToFloat64("invalid")) + }) + + t.Run("from_bool", func(t *testing.T) { + assert.Equal(t, 1.0, utils.ToFloat64(true)) + assert.Equal(t, 0.0, utils.ToFloat64(false)) + }) + + t.Run("from_nil", func(t *testing.T) { + assert.Equal(t, 0.0, utils.ToFloat64(nil)) + }) +} + +func TestToTimestamp(t *testing.T) { + t.Run("from_time_Time", func(t *testing.T) { + now := time.Now() + result := utils.ToTimestamp(now) + assert.NotNil(t, result) + assert.Equal(t, now.Unix(), result.Unix()) + }) + + t.Run("from_time_Time_pointer", func(t *testing.T) { + now := time.Now() + result := utils.ToTimestamp(&now) + assert.NotNil(t, result) + assert.Equal(t, now.Unix(), result.Unix()) + }) + + t.Run("from_RFC3339_string", func(t *testing.T) { + result := utils.ToTimestamp("2024-01-15T14:30:00Z") + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + assert.Equal(t, time.January, result.Month()) + assert.Equal(t, 15, result.Day()) + assert.Equal(t, 14, result.Hour()) + assert.Equal(t, 30, result.Minute()) + }) + + t.Run("from_datetime_string", func(t *testing.T) { + result := utils.ToTimestamp("2024-01-15 14:30:00") + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + }) + + t.Run("from_date_string", func(t *testing.T) { + result := utils.ToTimestamp("2024-01-15") + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + assert.Equal(t, 15, result.Day()) + }) + + t.Run("from_unix_timestamp_int64", func(t *testing.T) { + // 2024-01-15 00:00:00 UTC + result := utils.ToTimestamp(int64(1705276800)) + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + }) + + t.Run("from_unix_timestamp_float64", func(t *testing.T) { + result := utils.ToTimestamp(float64(1705276800)) + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + }) + + t.Run("from_empty_string", func(t *testing.T) { + result := utils.ToTimestamp("") + assert.Nil(t, result) + }) + + t.Run("from_invalid_string", func(t *testing.T) { + result := utils.ToTimestamp("not a date") + assert.Nil(t, result) + }) + + t.Run("from_nil", func(t *testing.T) { + result := utils.ToTimestamp(nil) + assert.Nil(t, result) + }) +} + +func TestToJSONValue(t *testing.T) { + t.Run("from_json_string_object", func(t *testing.T) { + result := utils.ToJSONValue(`{"name":"test","age":30}`) + assert.NotNil(t, result) + m, ok := result.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "test", m["name"]) + assert.Equal(t, float64(30), m["age"]) + }) + + t.Run("from_json_string_array", func(t *testing.T) { + result := utils.ToJSONValue(`["a","b","c"]`) + assert.NotNil(t, result) + arr, ok := result.([]interface{}) + assert.True(t, ok) + assert.Len(t, arr, 3) + assert.Equal(t, "a", arr[0]) + }) + + t.Run("from_bytes", func(t *testing.T) { + result := utils.ToJSONValue([]byte(`{"key":"value"}`)) + assert.NotNil(t, result) + m, ok := result.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "value", m["key"]) + }) + + t.Run("from_already_parsed_map", func(t *testing.T) { + input := map[string]interface{}{"foo": "bar"} + result := utils.ToJSONValue(input) + assert.Equal(t, input, result) + }) + + t.Run("from_already_parsed_array", func(t *testing.T) { + input := []interface{}{"a", "b"} + result := utils.ToJSONValue(input) + assert.Equal(t, input, result) + }) + + t.Run("from_empty_string", func(t *testing.T) { + result := utils.ToJSONValue("") + assert.Nil(t, result) + }) + + t.Run("from_empty_bytes", func(t *testing.T) { + result := utils.ToJSONValue([]byte{}) + assert.Nil(t, result) + }) + + t.Run("from_invalid_json", func(t *testing.T) { + result := utils.ToJSONValue("not json") + assert.Nil(t, result) + }) + + t.Run("from_nil", func(t *testing.T) { + result := utils.ToJSONValue(nil) + assert.Nil(t, result) + }) + + t.Run("from_other_type_passthrough", func(t *testing.T) { + // Non-string, non-[]byte types are passed through + result := utils.ToJSONValue(42) + assert.Equal(t, 42, result) + }) +} + +// ==================== Get Tests ==================== + +func TestGetString(t *testing.T) { + m := map[string]interface{}{ + "name": "test", + "number": 42, + "bool": true, + "nil": nil, + } + + t.Run("existing_string_key", func(t *testing.T) { + assert.Equal(t, "test", utils.GetString(m, "name")) + }) + + t.Run("converts_number_to_string", func(t *testing.T) { + assert.Equal(t, "42", utils.GetString(m, "number")) + }) + + t.Run("converts_bool_to_string", func(t *testing.T) { + assert.Equal(t, "true", utils.GetString(m, "bool")) + }) + + t.Run("non_existent_key", func(t *testing.T) { + assert.Equal(t, "", utils.GetString(m, "missing")) + }) + + t.Run("nil_map", func(t *testing.T) { + assert.Equal(t, "", utils.GetString(nil, "key")) + }) + + t.Run("nil_value", func(t *testing.T) { + assert.Equal(t, "", utils.GetString(m, "nil")) + }) +} + +func TestGetBool(t *testing.T) { + m := map[string]interface{}{ + "bool_true": true, + "bool_false": false, + "int_one": 1, + "int_zero": 0, + "string_true": "true", + } + + t.Run("bool_true", func(t *testing.T) { + assert.True(t, utils.GetBool(m, "bool_true")) + }) + + t.Run("bool_false", func(t *testing.T) { + assert.False(t, utils.GetBool(m, "bool_false")) + }) + + t.Run("int_one", func(t *testing.T) { + assert.True(t, utils.GetBool(m, "int_one")) + }) + + t.Run("int_zero", func(t *testing.T) { + assert.False(t, utils.GetBool(m, "int_zero")) + }) + + t.Run("string_true", func(t *testing.T) { + assert.True(t, utils.GetBool(m, "string_true")) + }) + + t.Run("non_existent_key", func(t *testing.T) { + assert.False(t, utils.GetBool(m, "missing")) + }) + + t.Run("nil_map", func(t *testing.T) { + assert.False(t, utils.GetBool(nil, "key")) + }) +} + +func TestGetInt(t *testing.T) { + m := map[string]interface{}{ + "int": 42, + "int64": int64(100), + "float64": 3.14, + "string": "123", + } + + t.Run("int", func(t *testing.T) { + assert.Equal(t, 42, utils.GetInt(m, "int")) + }) + + t.Run("int64", func(t *testing.T) { + assert.Equal(t, 100, utils.GetInt(m, "int64")) + }) + + t.Run("float64", func(t *testing.T) { + assert.Equal(t, 3, utils.GetInt(m, "float64")) + }) + + t.Run("string", func(t *testing.T) { + assert.Equal(t, 123, utils.GetInt(m, "string")) + }) + + t.Run("non_existent_key", func(t *testing.T) { + assert.Equal(t, 0, utils.GetInt(m, "missing")) + }) + + t.Run("nil_map", func(t *testing.T) { + assert.Equal(t, 0, utils.GetInt(nil, "key")) + }) +} + +func TestGetInt64(t *testing.T) { + m := map[string]interface{}{ + "int64": int64(9223372036854775807), + "int": 42, + "string": "123456789", + } + + t.Run("int64", func(t *testing.T) { + assert.Equal(t, int64(9223372036854775807), utils.GetInt64(m, "int64")) + }) + + t.Run("int", func(t *testing.T) { + assert.Equal(t, int64(42), utils.GetInt64(m, "int")) + }) + + t.Run("string", func(t *testing.T) { + assert.Equal(t, int64(123456789), utils.GetInt64(m, "string")) + }) + + t.Run("nil_map", func(t *testing.T) { + assert.Equal(t, int64(0), utils.GetInt64(nil, "key")) + }) +} + +func TestGetFloat64(t *testing.T) { + m := map[string]interface{}{ + "float64": 3.14159, + "int": 42, + "string": "2.718", + } + + t.Run("float64", func(t *testing.T) { + assert.Equal(t, 3.14159, utils.GetFloat64(m, "float64")) + }) + + t.Run("int", func(t *testing.T) { + assert.Equal(t, 42.0, utils.GetFloat64(m, "int")) + }) + + t.Run("string", func(t *testing.T) { + assert.InDelta(t, 2.718, utils.GetFloat64(m, "string"), 0.001) + }) + + t.Run("nil_map", func(t *testing.T) { + assert.Equal(t, 0.0, utils.GetFloat64(nil, "key")) + }) +} + +func TestGetTimestamp(t *testing.T) { + now := time.Now() + m := map[string]interface{}{ + "time": now, + "time_ptr": &now, + "rfc3339": "2024-01-15T14:30:00Z", + "unix": int64(1705276800), + "empty": "", + "nil_value": nil, + } + + t.Run("time_value", func(t *testing.T) { + result := utils.GetTimestamp(m, "time") + assert.NotNil(t, result) + assert.Equal(t, now.Unix(), result.Unix()) + }) + + t.Run("time_ptr", func(t *testing.T) { + result := utils.GetTimestamp(m, "time_ptr") + assert.NotNil(t, result) + }) + + t.Run("rfc3339_string", func(t *testing.T) { + result := utils.GetTimestamp(m, "rfc3339") + assert.NotNil(t, result) + assert.Equal(t, 2024, result.Year()) + }) + + t.Run("unix_timestamp", func(t *testing.T) { + result := utils.GetTimestamp(m, "unix") + assert.NotNil(t, result) + }) + + t.Run("empty_string", func(t *testing.T) { + result := utils.GetTimestamp(m, "empty") + assert.Nil(t, result) + }) + + t.Run("nil_value", func(t *testing.T) { + result := utils.GetTimestamp(m, "nil_value") + assert.Nil(t, result) + }) + + t.Run("non_existent_key", func(t *testing.T) { + result := utils.GetTimestamp(m, "missing") + assert.Nil(t, result) + }) + + t.Run("nil_map", func(t *testing.T) { + result := utils.GetTimestamp(nil, "key") + assert.Nil(t, result) + }) +} + +func TestGetJSONValue(t *testing.T) { + m := map[string]interface{}{ + "json_string": `{"nested":"value"}`, + "json_array": `[1,2,3]`, + "parsed_map": map[string]interface{}{"foo": "bar"}, + "empty": "", + "invalid": "not json", + } + + t.Run("json_string", func(t *testing.T) { + result := utils.GetJSONValue(m, "json_string") + assert.NotNil(t, result) + nested, ok := result.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "value", nested["nested"]) + }) + + t.Run("json_array", func(t *testing.T) { + result := utils.GetJSONValue(m, "json_array") + assert.NotNil(t, result) + arr, ok := result.([]interface{}) + assert.True(t, ok) + assert.Len(t, arr, 3) + }) + + t.Run("parsed_map", func(t *testing.T) { + result := utils.GetJSONValue(m, "parsed_map") + assert.NotNil(t, result) + parsed, ok := result.(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "bar", parsed["foo"]) + }) + + t.Run("empty_string", func(t *testing.T) { + result := utils.GetJSONValue(m, "empty") + assert.Nil(t, result) + }) + + t.Run("invalid_json", func(t *testing.T) { + result := utils.GetJSONValue(m, "invalid") + assert.Nil(t, result) + }) + + t.Run("nil_map", func(t *testing.T) { + result := utils.GetJSONValue(nil, "key") + assert.Nil(t, result) + }) +} + +// ==================== ToString Extended Tests ==================== + +func TestToStringExtended(t *testing.T) { + t.Run("from_nil", func(t *testing.T) { + assert.Equal(t, "", utils.ToString(nil)) + }) + + t.Run("from_bytes", func(t *testing.T) { + assert.Equal(t, "hello", utils.ToString([]byte("hello"))) + }) + + t.Run("from_int_types", func(t *testing.T) { + assert.Equal(t, "8", utils.ToString(int8(8))) + assert.Equal(t, "16", utils.ToString(int16(16))) + assert.Equal(t, "32", utils.ToString(int32(32))) + assert.Equal(t, "64", utils.ToString(int64(64))) + }) + + t.Run("from_uint_types", func(t *testing.T) { + assert.Equal(t, "8", utils.ToString(uint8(8))) + assert.Equal(t, "16", utils.ToString(uint16(16))) + assert.Equal(t, "32", utils.ToString(uint32(32))) + assert.Equal(t, "64", utils.ToString(uint64(64))) + }) + + t.Run("from_float_formats_nicely", func(t *testing.T) { + assert.Equal(t, "3.14", utils.ToString(3.14)) + assert.Equal(t, "1000", utils.ToString(1000.0)) // no trailing zeros + }) + + t.Run("from_struct_to_json", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name"` + } + result := utils.ToString(TestStruct{Name: "test"}) + assert.Contains(t, result, "test") + }) +} diff --git a/agent/store/types/types.go b/agent/store/types/types.go index 7ada83e5..d433a61b 100644 --- a/agent/store/types/types.go +++ b/agent/store/types/types.go @@ -174,7 +174,8 @@ const ( // Used for filtering and pagination when retrieving assistant lists type AssistantFilter struct { Tags []string `json:"tags,omitempty"` // Filter by tags - Type string `json:"type,omitempty"` // Filter by type + Type string `json:"type,omitempty"` // Filter by type (single value) + Types []string `json:"types,omitempty"` // Filter by types (multiple values, IN query) Keywords string `json:"keywords,omitempty"` // Search in name and description Connector string `json:"connector,omitempty"` // Filter by connector AssistantID string `json:"assistant_id,omitempty"` // Filter by assistant ID diff --git a/agent/store/xun/assistant.go b/agent/store/xun/assistant.go index abe4eb49..e583be99 100644 --- a/agent/store/xun/assistant.go +++ b/agent/store/xun/assistant.go @@ -353,11 +353,16 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string) }) } - // Apply type filter if provided + // Apply type filter if provided (single value) if filter.Type != "" { qb.Where("type", filter.Type) } + // Apply types filter if provided (multiple values, IN query) + if len(filter.Types) > 0 { + qb.WhereIn("type", filter.Types) + } + // Apply connector filter if provided if filter.Connector != "" { qb.Where("connector", filter.Connector) diff --git a/agent/types/types.go b/agent/types/types.go index 342bee2f..f63d1359 100644 --- a/agent/types/types.go +++ b/agent/types/types.go @@ -40,13 +40,14 @@ type DSL struct { // Uses the default assistant settings // =============================== type Uses struct { - Default string `json:"default,omitempty" yaml:"default,omitempty"` // The default assistant to use - Title string `json:"title,omitempty" yaml:"title,omitempty"` // The assistant for generating the topic title. - Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // The assistant for generating the prompt. - Vision string `json:"vision,omitempty" yaml:"vision,omitempty"` // The assistant for generating the image/video description, if the assistant enable the vision and model not support vision, use the vision model to describe the image/video, and return the messages with the image/video's description. Format: "agent" or "mcp:mcp_server_id" - Audio string `json:"audio,omitempty" yaml:"audio,omitempty"` // The assistant for processing audio (speech-to-text, text-to-speech). If the model doesn't support audio, use this to convert audio to text. Format: "agent" or "mcp:mcp_server_id" - Search string `json:"search,omitempty" yaml:"search,omitempty"` // The assistant for searching the knowledge, global web search. If not set, and the assistant enable the knowledge, it will search the result from the knowledge automatically. - Fetch string `json:"fetch,omitempty" yaml:"fetch,omitempty"` // The assistant for fetching the http/https/ftp/sftp/etc. file, and return the file's content. if not set, use the http process to fetch the file. + Default string `json:"default,omitempty" yaml:"default,omitempty"` // The default assistant to use + Title string `json:"title,omitempty" yaml:"title,omitempty"` // The assistant for generating the topic title. + Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // The assistant for generating the prompt. + RobotPrompt string `json:"robot_prompt,omitempty" yaml:"robot_prompt,omitempty"` // The assistant for generating Robot's system prompt (responsibilities description). + Vision string `json:"vision,omitempty" yaml:"vision,omitempty"` // The assistant for generating the image/video description, if the assistant enable the vision and model not support vision, use the vision model to describe the image/video, and return the messages with the image/video's description. Format: "agent" or "mcp:mcp_server_id" + Audio string `json:"audio,omitempty" yaml:"audio,omitempty"` // The assistant for processing audio (speech-to-text, text-to-speech). If the model doesn't support audio, use this to convert audio to text. Format: "agent" or "mcp:mcp_server_id" + Search string `json:"search,omitempty" yaml:"search,omitempty"` // The assistant for searching the knowledge, global web search. If not set, and the assistant enable the knowledge, it will search the result from the knowledge automatically. + Fetch string `json:"fetch,omitempty" yaml:"fetch,omitempty"` // The assistant for fetching the http/https/ftp/sftp/etc. file, and return the file's content. if not set, use the http process to fetch the file. // Search-related processing tools (NLP) Web string `json:"web,omitempty" yaml:"web,omitempty"` // Web search handler: "builtin", "", "mcp:." @@ -58,13 +59,14 @@ type Uses struct { // System configures connectors for system agents // =============================== type System struct { - Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default connector for all system agents - Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Connector for __yao.keyword agent - QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // Connector for __yao.querydsl agent - Title string `json:"title,omitempty" yaml:"title,omitempty"` // Connector for __yao.title agent - Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // Connector for __yao.prompt agent - NeedSearch string `json:"needsearch,omitempty" yaml:"needsearch,omitempty"` // Connector for __yao.needsearch agent - Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` // Connector for __yao.entity agent + Default string `json:"default,omitempty" yaml:"default,omitempty"` // Default connector for all system agents + Keyword string `json:"keyword,omitempty" yaml:"keyword,omitempty"` // Connector for __yao.keyword agent + QueryDSL string `json:"querydsl,omitempty" yaml:"querydsl,omitempty"` // Connector for __yao.querydsl agent + Title string `json:"title,omitempty" yaml:"title,omitempty"` // Connector for __yao.title agent + Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` // Connector for __yao.prompt agent + RobotPrompt string `json:"robot_prompt,omitempty" yaml:"robot_prompt,omitempty"` // Connector for __yao.robot_prompt agent + NeedSearch string `json:"needsearch,omitempty" yaml:"needsearch,omitempty"` // Connector for __yao.needsearch agent + Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` // Connector for __yao.entity agent } // Mention Structure diff --git a/data/bindata.go b/data/bindata.go index 25e395c7..c5b0e30e 100644 --- a/data/bindata.go +++ b/data/bindata.go @@ -86,6 +86,8 @@ // .tmp/data/yao/assistants/querydsl/prompts/join.yml // .tmp/data/yao/assistants/querydsl/prompts.yml // .tmp/data/yao/assistants/querydsl/src/index.ts +// .tmp/data/yao/assistants/robot_prompt/package.yao +// .tmp/data/yao/assistants/robot_prompt/prompts.yml // .tmp/data/yao/assistants/title/package.yao // .tmp/data/yao/assistants/title/prompts.yml // .tmp/data/yao/data/icons/404.png @@ -344,7 +346,7 @@ func cuiSetupIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/setup/index.html", size: 10, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "cui/setup/index.html", size: 10, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -364,7 +366,7 @@ func cuiV09IndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v0.9/index.html", size: 13, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "cui/v0.9/index.html", size: 13, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -384,7 +386,7 @@ func cuiV10IndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v1.0/index.html", size: 49, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "cui/v1.0/index.html", size: 49, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -404,7 +406,7 @@ func cuiV10Layouts__indexAsyncJs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v1.0/layouts__index.async.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "cui/v1.0/layouts__index.async.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -424,7 +426,7 @@ func cuiV10UmiJs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v1.0/umi.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "cui/v1.0/umi.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -444,7 +446,7 @@ func initEnv() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.env", size: 219, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.env", size: 219, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -464,7 +466,7 @@ func initVscodeSettingsJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/settings.json", size: 4666, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/settings.json", size: 4666, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -484,7 +486,7 @@ func initVscodeTypesRuntimeConsoleDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/console.d.ts", size: 221, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/console.d.ts", size: 221, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -504,7 +506,7 @@ func initVscodeTypesRuntimeExceptionDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/exception.d.ts", size: 738, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/exception.d.ts", size: 738, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -524,7 +526,7 @@ func initVscodeTypesRuntimeFsDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/fs.d.ts", size: 8554, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/fs.d.ts", size: 8554, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -544,7 +546,7 @@ func initVscodeTypesRuntimeGlobalDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/global.d.ts", size: 1759, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/global.d.ts", size: 1759, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -564,7 +566,7 @@ func initVscodeTypesRuntimeHttpDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/http.d.ts", size: 6179, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/http.d.ts", size: 6179, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -584,7 +586,7 @@ func initVscodeTypesRuntimeIoDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/io.d.ts", size: 587, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/io.d.ts", size: 587, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -604,7 +606,7 @@ func initVscodeTypesRuntimeLogDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/log.d.ts", size: 1692, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/log.d.ts", size: 1692, mode: os.FileMode(493), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -624,7 +626,7 @@ func initVscodeTypesRuntimeNeoDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/neo.d.ts", size: 3750, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/neo.d.ts", size: 3750, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -644,7 +646,7 @@ func initVscodeTypesRuntimeProcessFsDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process/fs.d.ts", size: 11133, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process/fs.d.ts", size: 11133, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -664,7 +666,7 @@ func initVscodeTypesRuntimeProcessHttpDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process/http.d.ts", size: 5653, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process/http.d.ts", size: 5653, mode: os.FileMode(493), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -684,7 +686,7 @@ func initVscodeTypesRuntimeProcessModelDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process/model.d.ts", size: 6656, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process/model.d.ts", size: 6656, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -704,7 +706,7 @@ func initVscodeTypesRuntimeProcessDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process.d.ts", size: 23165, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process.d.ts", size: 23165, mode: os.FileMode(493), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -724,7 +726,7 @@ func initVscodeTypesRuntimeQueryDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/query.d.ts", size: 6124, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/query.d.ts", size: 6124, mode: os.FileMode(493), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -744,7 +746,7 @@ func initVscodeTypesRuntimeStoreDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/store.d.ts", size: 2251, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/store.d.ts", size: 2251, mode: os.FileMode(493), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -764,7 +766,7 @@ func initVscodeTypesRuntimeSuiDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/sui.d.ts", size: 1713, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/sui.d.ts", size: 1713, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -784,7 +786,7 @@ func initVscodeTypesRuntimeTimeDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/time.d.ts", size: 711, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/time.d.ts", size: 711, mode: os.FileMode(493), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -804,7 +806,7 @@ func initVscodeTypesRuntimeDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime.d.ts", size: 424, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime.d.ts", size: 424, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -824,7 +826,7 @@ func initVscodeTypesSuiDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/sui.d.ts", size: 8931, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/.vscode/types/sui.d.ts", size: 8931, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -844,7 +846,7 @@ func initAppYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/app.yao", size: 3115, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/app.yao", size: 3115, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -864,7 +866,7 @@ func initDataReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/README.md", size: 41, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/data/README.md", size: 41, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -884,7 +886,7 @@ func initDataTemplatesDefault__assetsReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -904,7 +906,7 @@ func initDataTemplatesDefault__assetsImagesIconsAppPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -924,7 +926,7 @@ func initDataTemplatesDefault__assetsImagesLogosLogo_colorSvg() (*asset, error) return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -944,7 +946,7 @@ func initDataTemplatesDefault__assetsImagesLogosWordmarkSvg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -964,7 +966,7 @@ func initDataTemplatesDefault__dataJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__data.json", size: 30, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__data.json", size: 30, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -984,7 +986,7 @@ func initDataTemplatesDefault__documentHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__document.html", size: 492, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__document.html", size: 492, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1004,7 +1006,7 @@ func initDataTemplatesDefaultIndexIndexCss() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/index/index.css", size: 2896, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/data/templates/default/index/index.css", size: 2896, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1024,7 +1026,7 @@ func initDataTemplatesDefaultIndexIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/index/index.html", size: 2361, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/data/templates/default/index/index.html", size: 2361, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1044,7 +1046,7 @@ func initDataTemplatesDefaultIndexIndexJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/index/index.json", size: 31, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/data/templates/default/index/index.json", size: 31, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1064,7 +1066,7 @@ func initDbReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/db/README.md", size: 84, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/db/README.md", size: 84, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1084,7 +1086,7 @@ func initFlowsMenuFlowYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/flows/menu.flow.yao", size: 813, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/flows/menu.flow.yao", size: 813, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1104,7 +1106,7 @@ func initFormsAccountFormYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/forms/account.form.yao", size: 1194, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/forms/account.form.yao", size: 1194, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1124,7 +1126,7 @@ func initIconsAppIcns() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/icons/app.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/icons/app.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1144,7 +1146,7 @@ func initIconsAppIco() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/icons/app.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/icons/app.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1164,7 +1166,7 @@ func initIconsAppPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1184,7 +1186,7 @@ func initLoginsAdminLoginYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/logins/admin.login.yao", size: 302, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/logins/admin.login.yao", size: 302, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1204,7 +1206,7 @@ func initLogsReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/logs/README.md", size: 28, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/logs/README.md", size: 28, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1224,7 +1226,7 @@ func initModelsAdminUserModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/models/admin/user.mod.yao", size: 6416, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/models/admin/user.mod.yao", size: 6416, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1244,7 +1246,7 @@ func initModelsTestsPetModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/models/tests/pet.mod.yao", size: 525, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/models/tests/pet.mod.yao", size: 525, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1264,7 +1266,7 @@ func initNeoNeoYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/neo/neo.yml", size: 724, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/neo/neo.yml", size: 724, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1284,7 +1286,7 @@ func initPublicReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/README.md", size: 108, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/public/README.md", size: 108, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1304,7 +1306,7 @@ func initPublicAssetsReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/public/assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1324,7 +1326,7 @@ func initPublicAssetsImagesIconsAppPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/public/assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1344,7 +1346,7 @@ func initPublicAssetsImagesLogosLogo_colorSvg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/public/assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1364,7 +1366,7 @@ func initPublicAssetsImagesLogosWordmarkSvg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/public/assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1384,7 +1386,7 @@ func initPublicAssetsLibsuiMinJs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/libsui.min.js", size: 12569, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/public/assets/libsui.min.js", size: 12569, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1404,7 +1406,7 @@ func initPublicAssetsLibsuiMinJsMap() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/libsui.min.js.map", size: 38553, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/public/assets/libsui.min.js.map", size: 38553, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1424,7 +1426,7 @@ func initPublicIndexCfg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/index.cfg", size: 85, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/public/index.cfg", size: 85, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1444,7 +1446,7 @@ func initPublicIndexSui() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/index.sui", size: 5682, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/public/index.sui", size: 5682, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1464,7 +1466,7 @@ func initScriptsAccountTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/account.ts", size: 2521, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/scripts/account.ts", size: 2521, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1484,7 +1486,7 @@ func initScriptsAiNeoTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/ai/neo.ts", size: 375, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/scripts/ai/neo.ts", size: 375, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1504,7 +1506,7 @@ func initScriptsTestsTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/tests.ts", size: 1044, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/scripts/tests.ts", size: 1044, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1524,7 +1526,7 @@ func initScriptsUtilsTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/utils.ts", size: 1230, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/scripts/utils.ts", size: 1230, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1544,7 +1546,7 @@ func initSuisWebSuiYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/suis/web.sui.yao", size: 675, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/suis/web.sui.yao", size: 675, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1564,7 +1566,7 @@ func initTablesAccountTabYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/tables/account.tab.yao", size: 5597, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/tables/account.tab.yao", size: 5597, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1584,7 +1586,7 @@ func initTsconfigJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/tsconfig.json", size: 178, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "init/tsconfig.json", size: 178, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1604,7 +1606,7 @@ func libsuiIndexTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/index.ts", size: 13047, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "libsui/index.ts", size: 13047, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1624,7 +1626,7 @@ func libsuiOpenapiTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/openapi.ts", size: 22959, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "libsui/openapi.ts", size: 22959, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1644,7 +1646,7 @@ func libsuiUtilsTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/utils.ts", size: 5959, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "libsui/utils.ts", size: 5959, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1664,7 +1666,7 @@ func libsuiYaoTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/yao.ts", size: 4338, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "libsui/yao.ts", size: 4338, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1684,7 +1686,7 @@ func publicIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "public/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "public/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1704,7 +1706,7 @@ func uiIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "ui/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "ui/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1724,7 +1726,7 @@ func yaoAssistantsEntityPackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/entity/package.yao", size: 212, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/entity/package.yao", size: 212, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1744,7 +1746,7 @@ func yaoAssistantsEntityPromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/entity/prompts.yml", size: 930, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/entity/prompts.yml", size: 930, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1764,7 +1766,7 @@ func yaoAssistantsKeywordPackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/keyword/package.yao", size: 200, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/keyword/package.yao", size: 200, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1784,7 +1786,7 @@ func yaoAssistantsKeywordPromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/keyword/prompts.yml", size: 990, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/keyword/prompts.yml", size: 990, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1804,7 +1806,7 @@ func yaoAssistantsKeywordSrcIndexTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/keyword/src/index.ts", size: 4104, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/keyword/src/index.ts", size: 4104, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1824,7 +1826,7 @@ func yaoAssistantsNeedsearchPackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/needsearch/package.yao", size: 207, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/needsearch/package.yao", size: 207, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1844,7 +1846,7 @@ func yaoAssistantsNeedsearchPromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/needsearch/prompts.yml", size: 3092, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/needsearch/prompts.yml", size: 3092, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1864,7 +1866,7 @@ func yaoAssistantsNeedsearchSrcIndexTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/needsearch/src/index.ts", size: 2767, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/needsearch/src/index.ts", size: 2767, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1884,7 +1886,7 @@ func yaoAssistantsPromptPackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/prompt/package.yao", size: 186, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/prompt/package.yao", size: 186, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1904,7 +1906,7 @@ func yaoAssistantsPromptPromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/prompt/prompts.yml", size: 621, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/prompt/prompts.yml", size: 621, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1924,7 +1926,7 @@ func yaoAssistantsQuerydslPackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/package.yao", size: 196, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/package.yao", size: 196, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1944,7 +1946,7 @@ func yaoAssistantsQuerydslPromptsAggregationYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/aggregation.yml", size: 6982, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/aggregation.yml", size: 6982, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1964,7 +1966,7 @@ func yaoAssistantsQuerydslPromptsComplexYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/complex.yml", size: 7352, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/complex.yml", size: 7352, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1984,7 +1986,7 @@ func yaoAssistantsQuerydslPromptsFilterYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/filter.yml", size: 7087, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/filter.yml", size: 7087, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2004,7 +2006,7 @@ func yaoAssistantsQuerydslPromptsJoinYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/join.yml", size: 8167, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/join.yml", size: 8167, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2024,7 +2026,7 @@ func yaoAssistantsQuerydslPromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/prompts.yml", size: 5836, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/prompts.yml", size: 5836, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2044,7 +2046,47 @@ func yaoAssistantsQuerydslSrcIndexTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/src/index.ts", size: 1873, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/src/index.ts", size: 1873, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _yaoAssistantsRobot_promptPackageYao = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x2c\x8d\x41\x0a\xc2\x30\x10\x45\xf7\x39\xc5\x27\x6b\x11\x77\x42\x2f\xe0\x56\xbc\x41\xda\x8e\x58\x34\x99\x30\x33\x41\x4a\xe9\xdd\x65\x62\xd7\xff\xfd\xf7\xb6\x00\xc4\x92\x32\xc5\x01\xf1\xc1\x23\x1b\xee\xc2\xb9\x1a\x6e\x54\x48\x92\xb1\xc4\x93\x33\x33\xe9\x24\x4b\xb5\x85\x8b\xa3\xc7\x4a\xd0\x55\x8d\x32\x6a\x3f\x29\x9e\x2c\x48\xcd\xb8\x70\xe6\xa6\x10\x37\xea\xdf\x60\x6b\xed\x95\x2f\xcb\x9b\x0e\x6b\x53\xd2\x38\x60\x43\x54\x4a\x32\xbd\x7c\x9f\x17\x4d\xe3\x87\xe6\x88\xbd\x33\xdc\xa3\x1d\x0b\x80\x8b\x28\x57\x8f\x37\x71\xdf\xe5\x7c\x0d\xc0\x1e\xf6\xf0\x0b\x00\x00\xff\xff\xb0\x88\xf0\x9c\xcc\x00\x00\x00") + +func yaoAssistantsRobot_promptPackageYaoBytes() ([]byte, error) { + return bindataRead( + _yaoAssistantsRobot_promptPackageYao, + "yao/assistants/robot_prompt/package.yao", + ) +} + +func yaoAssistantsRobot_promptPackageYao() (*asset, error) { + bytes, err := yaoAssistantsRobot_promptPackageYaoBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "yao/assistants/robot_prompt/package.yao", size: 204, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _yaoAssistantsRobot_promptPromptsYml = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x8c\x55\x5d\x4f\x23\x47\x16\x7d\x9f\x5f\x71\x95\x3c\x6c\x22\x61\xa2\x4c\xf6\x89\x37\x34\x3b\x1a\xcd\x6a\x76\x26\x9a\xd9\x28\x8a\x22\xa4\x29\xba\xaf\xed\x12\xdd\x55\xbd\x55\xd5\x03\x46\xfb\x60\x06\x30\x0e\x63\x03\x21\x12\x61\x86\x2f\x13\x20\xb0\xd1\xc6\x48\x0b\x03\xc4\x7c\xe4\xc7\xac\xab\xba\xfd\x94\xbf\xb0\xaa\xee\x36\xd8\xb0\xab\xec\x8b\xd5\x5d\x7d\xee\xad\x73\xef\x39\xf7\x3a\x07\x82\x7b\x38\x04\xb2\x24\x15\xfa\xf7\x00\x1c\xce\x14\x32\x35\x04\x7f\xbf\x07\x00\xf0\x15\x0f\x81\x08\x04\xc2\x00\x27\x02\x14\x0a\x88\x02\x47\x90\xbc\xa2\xac\x90\x45\x41\x20\xb8\x1f\x28\x09\x79\x2e\x80\x84\x8a\x33\xee\xf3\x50\xc2\xf0\x63\x10\x7c\x94\x2b\x09\x1f\x91\x02\x32\x25\x3f\x1e\x4c\x72\x26\x3f\x7f\x25\x72\x6c\x28\x79\x7a\x44\x5f\x21\x03\x02\xa3\x82\x62\x3e\xa1\x03\x2e\x4a\x47\xd0\x40\x51\xce\x06\xa0\x80\x0c\x05\x51\x08\x04\x1c\xee\x07\x02\x8b\xc8\x24\x7d\x85\xfd\xb7\x83\x2a\x12\x05\x2e\xe6\x29\x43\x99\x26\xfe\x74\x10\x1e\xbb\xc8\x14\x55\xa5\x21\xf8\xb2\xc8\x41\x15\x31\x65\x04\x54\x26\x88\xfb\x83\xf0\x1c\x65\xc0\x99\xa4\xa3\xd4\xa3\x8a\xa2\xb4\x48\xa2\x7a\xa0\xb2\xc8\x43\xcf\x05\x97\x27\x11\x9f\x0d\xc2\x03\xce\xa4\x12\x84\x32\x25\x87\xe0\x79\xe8\xa1\x04\xc2\x5c\xf0\xa8\x4f\x15\xb1\x94\xd3\xdc\x7f\x1c\x84\x17\xaa\x64\x7b\xfb\x80\xfb\x7e\xc8\xa8\x93\x7c\x04\xc5\x19\x26\x01\x24\x08\x04\x27\x4e\xf1\xa6\x27\xcf\x42\x15\x84\x2a\x25\x9f\x83\xe7\xa8\x42\xc1\xe0\xd9\xd3\x27\x5f\x25\x74\x6e\x95\x8b\x13\x2a\x03\x3e\x7d\x06\x3e\x11\x63\x2e\x1f\x67\xe0\x70\x17\x61\xd4\xe3\xce\x98\x1c\xb0\x1f\xfe\x16\x72\x85\xe9\x23\x4e\x04\x1e\x61\x09\x89\x2c\xf0\xcf\xa1\x4c\x2b\xcd\x72\x66\xda\x03\x55\x12\xbd\x7c\x06\xfa\x42\x62\x82\x79\x31\xfc\x97\x87\xf0\x64\xf8\xe9\xa3\x2f\x86\x1f\x3d\x04\x22\x93\xc3\x50\xa2\xf8\x83\x04\xca\x82\x50\xdd\xd4\xf1\x42\x89\xd0\x51\xa1\x40\xf8\x88\xb8\x24\x50\x30\x4a\x24\xba\xc0\x59\xa2\xee\xc7\x69\x81\x2f\x5f\xbe\xec\x73\xd8\xd7\xb7\x95\x1f\xe9\x71\xcb\x87\x1f\xc2\x03\x2e\xf0\x8e\x5a\x19\xc7\xaf\xdd\x50\x95\xe0\xd3\x91\xbe\xd7\xfb\xfd\xaf\x9f\x8d\xf4\xa5\xfb\x92\x8b\x31\x6b\xe1\xcf\x05\x65\x0e\x0d\xbc\x9b\x5c\x41\xf7\xa4\x27\xe1\xcd\xd9\xfd\x91\x5b\xac\xae\xcd\xd0\xc5\x3a\xd7\x47\x3d\x09\x7a\x0e\xef\x64\xe8\x75\x47\x62\x99\x6e\x90\xb4\x2f\x50\x08\xa9\x8b\x1e\x65\x38\xd2\xd7\xb7\xe4\xe7\xe1\x04\xf1\x2d\xf7\xa1\x9b\xa3\xc7\x56\x8b\x21\xf8\xe0\x05\xb1\xce\x1c\x66\xc4\x2b\x49\xf5\xc1\x1d\x7f\x5d\x0f\x36\xf4\x01\x41\x74\x3b\xec\x61\x3a\xcf\xf6\x7c\x32\x19\xf6\x04\xe7\x12\x45\x12\xfb\x06\x82\xbf\xa2\xae\xfd\x40\x1c\x4b\x9d\xd8\x08\xca\x24\x2d\x14\x95\xfc\xff\xa5\x4b\xee\x9d\x44\x70\x09\xf5\x4a\x9f\x8c\x23\x8e\x79\xa5\x4f\x7c\xce\x54\xd1\x2b\x65\x57\x2a\x81\xcc\xed\xe2\xd3\x89\xce\x97\x40\xf1\x20\x17\xa0\xc8\x73\xe1\x5b\x12\x81\xe0\x6e\xe8\xa8\x74\x16\x05\x16\xae\xe7\x30\x07\x8f\xba\x0b\x24\x4d\x97\xe7\x02\x1d\x92\x94\x1a\x70\x71\xad\xdb\xb0\x67\xb7\x1b\x67\x20\x69\x81\xd1\x3c\x75\x08\x53\x40\x18\xf7\x89\x47\x51\x02\x17\xc0\x03\x8b\x0f\xd9\x0d\xfd\xdf\x35\xd3\xb0\x37\x4e\x4a\x32\xf1\xbf\x9d\x2e\xc7\x0b\xa5\x25\x66\xaf\xb1\x8d\x1c\x00\xc6\x15\x10\x29\x43\x3f\x50\x3d\x8c\x3f\x17\x94\x0b\xaa\xe8\x24\xfe\xb7\xe6\x02\x7f\x85\x02\x04\x19\x07\x69\x17\x8e\x54\xd4\xe9\x06\x5a\x3b\x52\x17\x05\x48\x24\x92\x33\xe2\x41\x9e\x38\x8a\x8b\xb4\x2d\x76\x4b\x60\x36\xe6\x13\xea\x77\x7c\xfc\x8c\x79\x25\x20\x8e\x83\x52\xda\xa5\x5e\xe4\x82\x4e\xa2\xdb\xe3\x02\x5b\x54\x17\xfc\x27\x9e\x54\xe2\x93\x31\xbb\x4c\xa8\x63\x9b\xc1\x05\xd8\x94\x0a\x0b\x25\x70\xd1\xa1\xb2\xa7\xc0\x87\xd2\x21\x5e\x22\x89\xdd\xe5\xca\x6e\xf3\x3c\x65\xd6\x4d\x12\x14\x07\x9f\x30\x52\x40\x1f\xd9\x6d\x92\xff\x6b\x54\x1e\x78\x48\xc4\x40\xd2\x62\x2a\x71\x00\x46\x43\x69\xff\x07\x64\x2e\xcf\x9d\xd0\xee\x1e\x8f\xb0\x42\x48\x0a\xd8\xb3\xd4\x9c\x22\x11\x99\x5f\x94\xed\xaf\x84\xf1\x22\x32\x08\x04\x4a\x6b\x31\x56\x48\xaa\xcc\x02\x9e\x20\x71\x61\x9c\xaa\x22\x8c\x61\xe9\x9a\xeb\x00\xb8\xa8\x08\xf5\xac\xa9\x3c\x8f\x8f\xdf\xb0\xcd\xe5\x72\x77\x47\xb2\x7d\xd9\x30\xab\x87\xfa\x74\x2f\x3a\x78\xa3\xcf\xaa\x77\x87\x32\x05\xb4\xcf\xca\x7a\xa9\xde\x3e\xfb\xce\x1c\x1d\xb4\x5b\x0b\x66\xbe\x6c\xd6\x0f\x3b\xdf\x37\x3b\x3f\xac\xc6\xfb\x3b\xba\x72\x14\xbd\x9b\xb9\x4e\xa2\xe7\xff\x61\xbe\x79\xf3\xef\xf2\xeb\xbe\x4e\x99\xc6\x99\xfe\x75\x3a\x9e\xaa\xc5\xc7\x3b\x59\x05\xba\x5a\x31\x9b\x4b\x7a\xb9\x16\x1f\xce\x9b\x95\x9f\x7b\xb3\x66\x08\xb3\xb8\xd4\xbe\x5a\x4b\xaf\x30\x2b\xbf\x98\xed\xaa\x5e\xae\x99\xf5\x72\xfb\xf2\x48\x37\x37\xe3\xd3\xf7\xfa\xbc\x15\x37\x9b\x19\x3a\xba\x58\xd1\x95\xb7\x7a\xb9\xa6\x9b\xdb\x66\x6b\xaf\x7d\xbe\x13\x35\xa6\xba\xdf\xd6\xbe\x35\x0b\xfb\xd1\xd1\x79\x74\xbe\xa5\xa7\xf6\x74\xeb\x34\x9a\x3f\x31\xe5\xa9\x3e\x8e\xfa\x74\xaf\x7d\xb9\xae\x17\xb6\x74\xf5\x6d\x97\xe3\x6c\x35\x5a\xaa\xc4\xfb\x3b\x29\x2f\xd3\xf8\x45\xaf\x35\x7e\xbb\xa8\xe9\x4a\xfd\x2e\xb9\x2c\xa6\x7d\xb1\xaa\x67\xab\x71\x79\x3a\x7e\xfb\x6d\x74\x70\xa4\x9b\xef\x4c\x79\xdf\xd2\x5a\x3c\x8c\xce\x8f\xcd\xfc\xae\x29\xef\x67\xd0\xce\xd4\x7b\x7d\xf5\x93\x5e\xfb\xa1\xb3\xba\x15\x5d\xac\x44\x8d\xa9\x78\x7f\x26\xae\x4d\xdb\x32\x37\x4f\xcc\xe6\x8c\x69\xcc\xe9\xb9\x4a\x1f\xcb\xa8\xf5\xa3\xd9\xd8\x32\x1b\xdb\xed\xf3\x93\xee\x8d\xe7\xb3\x7a\xfd\xc0\x2c\x54\xcd\xc6\xb4\x8d\x5e\x3b\xd6\x95\x59\xf3\x5d\xbd\x7d\xb9\x9e\x96\xdc\xbd\x6e\xae\xae\x77\xf7\xf5\xe2\xaa\x59\x3b\xee\xac\x97\xdb\xad\x96\x55\x6d\xbb\x19\x37\x77\xbb\x99\xce\xea\xf1\xdc\x4f\xf1\x76\x4d\x57\x12\xe2\x09\x89\xce\x5c\xdd\x6c\xce\xf4\xcb\xf9\xaf\xad\x4e\xf9\x5d\x67\x67\xc1\x34\x2e\xba\x62\x25\xfa\xe9\xb9\x4a\xb4\xdd\xfc\xed\xa2\x16\x6f\x1f\xc4\x57\x57\x51\xb3\x6c\x8e\xa7\xfa\xe4\x4c\x75\x89\x76\x5b\xed\xab\x37\xe6\xfb\x13\x73\x74\x60\x56\x17\xe2\xc3\x9f\xe3\x83\xb2\x5e\xae\x45\xdf\x54\xcd\xfa\x3f\xbb\xdd\xdf\xad\x9b\x8d\xd7\xe6\xc7\xd7\xfa\xd7\xf7\x9d\xd9\xba\x5e\xaa\x46\xbb\x2d\x33\xb3\xa8\xd7\xae\xe2\xc3\x63\xb3\xba\x70\xef\x3f\x01\x00\x00\xff\xff\x4e\xa7\x09\x80\x2e\x0a\x00\x00") + +func yaoAssistantsRobot_promptPromptsYmlBytes() ([]byte, error) { + return bindataRead( + _yaoAssistantsRobot_promptPromptsYml, + "yao/assistants/robot_prompt/prompts.yml", + ) +} + +func yaoAssistantsRobot_promptPromptsYml() (*asset, error) { + bytes, err := yaoAssistantsRobot_promptPromptsYmlBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "yao/assistants/robot_prompt/prompts.yml", size: 2606, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2064,7 +2106,7 @@ func yaoAssistantsTitlePackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/title/package.yao", size: 178, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/title/package.yao", size: 178, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2084,7 +2126,7 @@ func yaoAssistantsTitlePromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/title/prompts.yml", size: 958, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/assistants/title/prompts.yml", size: 958, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2104,7 +2146,7 @@ func yaoDataIcons404Png() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/404.png", size: 9342, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/icons/404.png", size: 9342, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2124,7 +2166,7 @@ func yaoDataIconsIconIcns() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/icon.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/icons/icon.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2144,7 +2186,7 @@ func yaoDataIconsIconIco() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/icon.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/icons/icon.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2164,7 +2206,7 @@ func yaoDataIconsIconPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/icon.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/icons/icon.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2184,7 +2226,7 @@ func yaoDataIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/index.html", size: 282, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/index.html", size: 282, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2204,7 +2246,7 @@ func yaoDataKbProvidersChunkingSemanticEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/chunking/semantic/en.json", size: 5543, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/chunking/semantic/en.json", size: 5543, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2224,7 +2266,7 @@ func yaoDataKbProvidersChunkingSemanticZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/chunking/semantic/zh-cn.json", size: 5446, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/chunking/semantic/zh-cn.json", size: 5446, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2244,7 +2286,7 @@ func yaoDataKbProvidersChunkingStructuredEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/chunking/structured/en.json", size: 2423, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/chunking/structured/en.json", size: 2423, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2264,7 +2306,7 @@ func yaoDataKbProvidersChunkingStructuredZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/chunking/structured/zh-cn.json", size: 2321, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/chunking/structured/zh-cn.json", size: 2321, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2284,7 +2326,7 @@ func yaoDataKbProvidersConverterMcpEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/mcp/en.json", size: 4235, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/mcp/en.json", size: 4235, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2304,7 +2346,7 @@ func yaoDataKbProvidersConverterMcpZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/mcp/zh-cn.json", size: 4060, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/mcp/zh-cn.json", size: 4060, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2324,7 +2366,7 @@ func yaoDataKbProvidersConverterOcrEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/ocr/en.json", size: 6631, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/ocr/en.json", size: 6631, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2344,7 +2386,7 @@ func yaoDataKbProvidersConverterOcrZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/ocr/zh-cn.json", size: 6501, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/ocr/zh-cn.json", size: 6501, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2364,7 +2406,7 @@ func yaoDataKbProvidersConverterOfficeEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/office/en.json", size: 5476, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/office/en.json", size: 5476, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2384,7 +2426,7 @@ func yaoDataKbProvidersConverterOfficeZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/office/zh-cn.json", size: 5356, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/office/zh-cn.json", size: 5356, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2404,7 +2446,7 @@ func yaoDataKbProvidersConverterUtf8EnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/utf8/en.json", size: 292, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/utf8/en.json", size: 292, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2424,7 +2466,7 @@ func yaoDataKbProvidersConverterUtf8ZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/utf8/zh-cn.json", size: 281, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/utf8/zh-cn.json", size: 281, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2444,7 +2486,7 @@ func yaoDataKbProvidersConverterVideoEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/video/en.json", size: 6411, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/video/en.json", size: 6411, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2464,7 +2506,7 @@ func yaoDataKbProvidersConverterVideoZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/video/zh-cn.json", size: 6297, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/video/zh-cn.json", size: 6297, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2484,7 +2526,7 @@ func yaoDataKbProvidersConverterVisionEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/vision/en.json", size: 4085, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/vision/en.json", size: 4085, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2504,7 +2546,7 @@ func yaoDataKbProvidersConverterVisionZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/vision/zh-cn.json", size: 3949, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/vision/zh-cn.json", size: 3949, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2524,7 +2566,7 @@ func yaoDataKbProvidersConverterWhisperEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/whisper/en.json", size: 4449, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/whisper/en.json", size: 4449, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2544,7 +2586,7 @@ func yaoDataKbProvidersConverterWhisperZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/whisper/zh-cn.json", size: 4312, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/whisper/zh-cn.json", size: 4312, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2564,7 +2606,7 @@ func yaoDataKbProvidersEmbeddingFastembedEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/embedding/fastembed/en.json", size: 6865, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/embedding/fastembed/en.json", size: 6865, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2584,7 +2626,7 @@ func yaoDataKbProvidersEmbeddingFastembedZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/embedding/fastembed/zh-cn.json", size: 6685, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/embedding/fastembed/zh-cn.json", size: 6685, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2604,7 +2646,7 @@ func yaoDataKbProvidersEmbeddingOpenaiEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/embedding/openai/en.json", size: 5636, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/embedding/openai/en.json", size: 5636, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2624,7 +2666,7 @@ func yaoDataKbProvidersEmbeddingOpenaiZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/embedding/openai/zh-cn.json", size: 5463, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/embedding/openai/zh-cn.json", size: 5463, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2644,7 +2686,7 @@ func yaoDataKbProvidersExtractionOpenaiEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/extraction/openai/en.json", size: 9110, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/extraction/openai/en.json", size: 9110, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2664,7 +2706,7 @@ func yaoDataKbProvidersExtractionOpenaiZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/extraction/openai/zh-cn.json", size: 8827, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/extraction/openai/zh-cn.json", size: 8827, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2684,7 +2726,7 @@ func yaoDataKbProvidersFetcherHttpEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/http/en.json", size: 5885, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/http/en.json", size: 5885, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2704,7 +2746,7 @@ func yaoDataKbProvidersFetcherHttpZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/http/zh-cn.json", size: 5925, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/http/zh-cn.json", size: 5925, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2724,7 +2766,7 @@ func yaoDataKbProvidersFetcherMcpEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/mcp/en.json", size: 6819, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/mcp/en.json", size: 6819, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2744,7 +2786,7 @@ func yaoDataKbProvidersFetcherMcpZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/mcp/zh-cn.json", size: 6611, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/mcp/zh-cn.json", size: 6611, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2764,7 +2806,7 @@ func yaoFieldsModelTransJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/fields/model.trans.json", size: 14938, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/fields/model.trans.json", size: 14938, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2784,7 +2826,7 @@ func yaoLangsEnUsJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/en-US.json", size: 66, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/langs/en-US.json", size: 66, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2804,7 +2846,7 @@ func yaoLangsZhCnGlobalYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-cn/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/langs/zh-cn/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2824,7 +2866,7 @@ func yaoLangsZhCnLoginsAdminLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-cn/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/langs/zh-cn/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2844,7 +2886,7 @@ func yaoLangsZhCnLoginsUserLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-cn/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/langs/zh-cn/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2864,7 +2906,7 @@ func yaoLangsZhHkGlobalYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-hk/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/langs/zh-hk/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2884,7 +2926,7 @@ func yaoLangsZhHkLoginsAdminLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-hk/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/langs/zh-hk/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2904,7 +2946,7 @@ func yaoLangsZhHkLoginsUserLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-hk/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/langs/zh-hk/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2924,7 +2966,7 @@ func yaoModelsAgentAssistantModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/assistant.mod.yao", size: 6945, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/agent/assistant.mod.yao", size: 6945, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2944,7 +2986,7 @@ func yaoModelsAgentChatModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/chat.mod.yao", size: 3093, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/agent/chat.mod.yao", size: 3093, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2964,7 +3006,7 @@ func yaoModelsAgentExecutionModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/execution.mod.yao", size: 4953, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/agent/execution.mod.yao", size: 4953, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2984,7 +3026,7 @@ func yaoModelsAgentMessageModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/message.mod.yao", size: 3712, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/agent/message.mod.yao", size: 3712, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3004,7 +3046,7 @@ func yaoModelsAgentResumeModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/resume.mod.yao", size: 3896, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/agent/resume.mod.yao", size: 3896, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3024,7 +3066,7 @@ func yaoModelsAgentSearchModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/search.mod.yao", size: 3103, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/agent/search.mod.yao", size: 3103, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3044,7 +3086,7 @@ func yaoModelsAttachmentModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/attachment.mod.yao", size: 4687, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/attachment.mod.yao", size: 4687, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3064,7 +3106,7 @@ func yaoModelsAuditModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/audit.mod.yao", size: 5588, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/audit.mod.yao", size: 5588, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3084,7 +3126,7 @@ func yaoModelsConfigModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/config.mod.yao", size: 1649, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/config.mod.yao", size: 1649, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3104,7 +3146,7 @@ func yaoModelsDslModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/dsl.mod.yao", size: 3826, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/dsl.mod.yao", size: 3826, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3124,7 +3166,7 @@ func yaoModelsInvitationModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/invitation.mod.yao", size: 6693, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/invitation.mod.yao", size: 6693, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3144,7 +3186,7 @@ func yaoModelsJobCategoryModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/job/category.mod.yao", size: 2041, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/job/category.mod.yao", size: 2041, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3164,7 +3206,7 @@ func yaoModelsJobExecutionModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/job/execution.mod.yao", size: 7201, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/job/execution.mod.yao", size: 7201, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3184,7 +3226,7 @@ func yaoModelsJobJobModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/job/job.mod.yao", size: 6429, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/job/job.mod.yao", size: 6429, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3204,7 +3246,7 @@ func yaoModelsJobLogModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/job/log.mod.yao", size: 4711, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/job/log.mod.yao", size: 4711, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3224,7 +3266,7 @@ func yaoModelsKbCollectionModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/kb/collection.mod.yao", size: 5390, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/kb/collection.mod.yao", size: 5390, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3244,7 +3286,7 @@ func yaoModelsKbDocumentModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/kb/document.mod.yao", size: 9906, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/kb/document.mod.yao", size: 9906, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3264,7 +3306,7 @@ func yaoModelsMemberModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/member.mod.yao", size: 14798, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/member.mod.yao", size: 14798, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3284,7 +3326,7 @@ func yaoModelsRoleModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/role.mod.yao", size: 6434, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/role.mod.yao", size: 6434, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3304,7 +3346,7 @@ func yaoModelsTeamModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/team.mod.yao", size: 15823, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/team.mod.yao", size: 15823, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3324,7 +3366,7 @@ func yaoModelsUserOauth_accountModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/user/oauth_account.mod.yao", size: 6928, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/user/oauth_account.mod.yao", size: 6928, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3344,7 +3386,7 @@ func yaoModelsUserTypeModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/user/type.mod.yao", size: 7502, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/user/type.mod.yao", size: 7502, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3364,7 +3406,7 @@ func yaoModelsUserModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/user.mod.yao", size: 12335, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/models/user.mod.yao", size: 12335, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3384,7 +3426,7 @@ func yaoReleaseAppYaz() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/release/app.yaz", size: 181682, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/release/app.yaz", size: 181682, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3404,7 +3446,7 @@ func yaoStoresAgentCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/agent/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3424,7 +3466,7 @@ func yaoStoresAgentMemoryChatXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/memory/chat.xun.yao", size: 497, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/agent/memory/chat.xun.yao", size: 497, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3444,7 +3486,7 @@ func yaoStoresAgentMemoryContextXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/memory/context.xun.yao", size: 507, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/agent/memory/context.xun.yao", size: 507, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3464,7 +3506,7 @@ func yaoStoresAgentMemoryTeamXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/memory/team.xun.yao", size: 483, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/agent/memory/team.xun.yao", size: 483, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3484,7 +3526,7 @@ func yaoStoresAgentMemoryUserXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/memory/user.xun.yao", size: 489, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/agent/memory/user.xun.yao", size: 489, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3504,7 +3546,7 @@ func yaoStoresCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/cache.lru.yao", size: 285, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/cache.lru.yao", size: 285, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3524,7 +3566,7 @@ func yaoStoresKbCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/kb/cache.lru.yao", size: 304, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/kb/cache.lru.yao", size: 304, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3544,7 +3586,7 @@ func yaoStoresKbStoreXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/kb/store.xun.yao", size: 373, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/kb/store.xun.yao", size: 373, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3564,7 +3606,7 @@ func yaoStoresOauthCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/oauth/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/oauth/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3584,7 +3626,7 @@ func yaoStoresOauthClientXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/oauth/client.xun.yao", size: 377, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/oauth/client.xun.yao", size: 377, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3604,7 +3646,7 @@ func yaoStoresOauthStoreXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/oauth/store.xun.yao", size: 401, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/oauth/store.xun.yao", size: 401, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3624,7 +3666,7 @@ func yaoStoresStoreXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/store.xun.yao", size: 369, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/stores/store.xun.yao", size: 369, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3644,7 +3686,7 @@ func yaoUploadersAttachmentLocalYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/uploaders/attachment.local.yao", size: 1163, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + info := bindataFileInfo{name: "yao/uploaders/attachment.local.yao", size: 1163, mode: os.FileMode(420), modTime: time.Unix(1769080845, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3787,6 +3829,8 @@ var _bindata = map[string]func() (*asset, error){ "yao/assistants/querydsl/prompts/join.yml": yaoAssistantsQuerydslPromptsJoinYml, "yao/assistants/querydsl/prompts.yml": yaoAssistantsQuerydslPromptsYml, "yao/assistants/querydsl/src/index.ts": yaoAssistantsQuerydslSrcIndexTs, + "yao/assistants/robot_prompt/package.yao": yaoAssistantsRobot_promptPackageYao, + "yao/assistants/robot_prompt/prompts.yml": yaoAssistantsRobot_promptPromptsYml, "yao/assistants/title/package.yao": yaoAssistantsTitlePackageYao, "yao/assistants/title/prompts.yml": yaoAssistantsTitlePromptsYml, "yao/data/icons/404.png": yaoDataIcons404Png, @@ -4096,6 +4140,10 @@ var _bintree = &bintree{nil, map[string]*bintree{ "index.ts": {yaoAssistantsQuerydslSrcIndexTs, map[string]*bintree{}}, }}, }}, + "robot_prompt": {nil, map[string]*bintree{ + "package.yao": {yaoAssistantsRobot_promptPackageYao, map[string]*bintree{}}, + "prompts.yml": {yaoAssistantsRobot_promptPromptsYml, map[string]*bintree{}}, + }}, "title": {nil, map[string]*bintree{ "package.yao": {yaoAssistantsTitlePackageYao, map[string]*bintree{}}, "prompts.yml": {yaoAssistantsTitlePromptsYml, map[string]*bintree{}}, 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/agent.go b/openapi/agent/agent.go index cd6bf4b6..72cd5d1f 100644 --- a/openapi/agent/agent.go +++ b/openapi/agent/agent.go @@ -2,6 +2,7 @@ package agent import ( "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/agent/robot" "github.com/yaoapp/yao/openapi/oauth/types" ) @@ -26,4 +27,8 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) { // Assistant Actions // group.POST("/assistants/:id/call", agent.HandleAssistantCall) // POST /assistants/:id/call - Execute assistant API + + // Robot routes - Attach as sub-router + // Routes: GET/POST /robots, GET/PUT/DELETE /robots/:id, GET /robots/:id/status + robot.Attach(group.Group("/robots"), oauth) } diff --git a/openapi/agent/assistant.go b/openapi/agent/assistant.go index d0c13487..276e0c4a 100644 --- a/openapi/agent/assistant.go +++ b/openapi/agent/assistant.go @@ -78,12 +78,24 @@ func ListAssistants(c *gin.Context) { // Parse filter parameters keywords := strings.TrimSpace(c.Query("keywords")) typeParam := strings.TrimSpace(c.Query("type")) - if typeParam == "" { - typeParam = "assistant" // Default type - } connector := strings.TrimSpace(c.Query("connector")) assistantID := strings.TrimSpace(c.Query("assistant_id")) + // Parse types (multiple, comma-separated for IN query) + var types []string + if typesParam := c.Query("types"); typesParam != "" { + types = strings.Split(typesParam, ",") + // Trim spaces + for i, t := range types { + types[i] = strings.TrimSpace(t) + } + } + + // Set default type only if neither type nor types is specified + if typeParam == "" && len(types) == 0 { + typeParam = "assistant" // Default type + } + // Parse assistant IDs (multiple) var assistantIDs []string if assistantIDsParam := c.Query("assistant_ids"); assistantIDsParam != "" { @@ -131,6 +143,7 @@ func ListAssistants(c *gin.Context) { PageSize: pagesize, Keywords: keywords, Type: typeParam, + Types: types, Connector: connector, AssistantID: assistantID, AssistantIDs: assistantIDs, diff --git a/openapi/agent/robot/DESIGN.md b/openapi/agent/robot/DESIGN.md new file mode 100644 index 00000000..63caebdc --- /dev/null +++ b/openapi/agent/robot/DESIGN.md @@ -0,0 +1,840 @@ +# Robot OpenAPI - Design Document + +> Based on: `yao/agent/robot/` (Backend), `cui/packages/cui/pages/mission-control/` (Frontend) +> Gap Analysis: `yao/openapi/agent/robot/GAPS.md` + +## 1. Overview + +### 1.1 Purpose + +Provide HTTP REST API endpoints for Robot Agent management, designed to support the Mission Control frontend UI. + +### 1.2 Implementation Strategy + +> **Low-risk phases first. Medium-risk features (Chat API, SSE Event Bus) can be deferred.** + +| Phase | Risk | Features | Frontend Fallback | +|-------|------|----------|-------------------| +| 1. Core CRUD | 🟢 Low | List, Get, Create, Update, Delete | - | +| 2. Execution Management | 🟢 Low | List, Get, Control executions | - | +| 3. Results & Activities | 🟢 Low | Deliverables, Activity feed | - | +| 4. i18n | 🟢 Low | Locale parameter support | - | +| 5. Chat API | 🟡 Medium (Deferred) | Multi-turn conversation | Single-submit mode | +| 6. SSE Event Bus | 🟡 Medium (Deferred) | Real-time status streams | Polling every 3-5s | + +### 1.3 Route Decision: `/v1/agent/robots` + +**Analysis of existing `openapi/` route structure:** + +| Package | Route | Description | +|---------|-------|-------------| +| `agent/` | `/v1/agent/assistants` | Assistant CRUD, info | +| `chat/` | `/v1/chat/completions` | Chat completions | +| `kb/` | `/v1/kb/collections` | Knowledge base | +| `job/` | `/v1/job/jobs` | Job management | +| `file/` | `/v1/file/*` | File operations | +| `user/` | `/v1/user/*` | User management | +| `team/` | `/v1/team/*` | Team management | + +**Decision:** Put Robot routes under `/v1/agent/robots` because: + +1. **Semantic Alignment**: Robot is a type of Agent (Autonomous Robot Agent), just like Assistant is a type of Agent +2. **Existing Pattern**: `openapi/agent/` already handles `/v1/agent/assistants` +3. **Logical Grouping**: Agent-related APIs grouped together +4. **Consistent Hierarchy**: `/v1/agent/{type}` pattern + +**Route Comparison:** + +| Option | Path | Verdict | +|--------|------|---------| +| ❌ `/v1/robots` | New top-level namespace | Inconsistent with agent grouping | +| ✅ `/v1/agent/robots` | Under agent namespace | Follows existing pattern | +| ❌ `/v1/members?type=robot` | Reuse members | Less intuitive for operations | + +### 1.4 Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Frontend (Mission Control) │ +│ cui/packages/cui/pages/mission-control/ │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ HTTP REST / SSE + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ OpenAPI Layer │ +│ yao/openapi/agent/ │ +│ - Routes: /v1/agent/assistants/* (existing) │ +│ - Routes: /v1/agent/robots/* (NEW) │ +│ - Auth: OAuth2 via Guard middleware │ +│ - SSE: Real-time updates │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Robot API Layer │ +│ yao/agent/robot/api/ │ +│ - Go functions: Get(), List(), Trigger(), etc. │ +│ - Business logic │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ Robot Core │ +│ yao/agent/robot/ │ +│ - Manager, Executor, Cache, Pool, Store │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.5 Design Principles + +1. **Layered Architecture**: OpenAPI layer only handles HTTP concerns (routing, request parsing, response formatting). Business logic stays in `robot/api/`. +2. **Consistent with Existing Patterns**: Follow `yao/openapi/agent/` conventions, extend existing agent package +3. **Incremental Implementation**: Start with core CRUD, then add real-time features +4. **Frontend-Backend Balance**: API design considers both frontend needs and backend capabilities + +--- + +## 2. Differences Analysis + +### 2.1 Frontend Expectations vs Backend Reality + +| Feature | Frontend (API.md) | Backend (robot/api/) | Gap | Solution | +|---------|-------------------|----------------------|-----|----------| +| Robot List | `GET /v1/robots` with `name`, `description` | `List()` returns `types.Robot` | Field mapping needed | Map in OpenAPI layer | +| Robot Detail | `GET /v1/robots/:id` with full `config` | `Get()` returns Robot + Config | Need format conversion | Map to frontend format | +| Create Robot | POST with `work_mode` | Not implemented | New feature | Add `Create()` | +| Update Robot | PUT with partial update | Not implemented | New feature | Add `Update()` | +| Delete Robot | DELETE | Not implemented | New feature | Add `Remove()` | +| Trigger | Immediate execution | `Trigger()` returns sync result | Works | Wrap with SSE events | +| Intervene | Immediate intervention | `Intervene()` returns sync result | Works | Wrap with SSE events | +| Multi-turn Chat | Chat before execute | Not implemented | **Deferred** | Frontend uses single-submit | +| Results List | `/results` endpoint | No separate results API | New feature | Derive from executions | +| Activities | `/activities` endpoint | No activities tracking | New feature | Derive from executions | +| Real-time Stream | SSE `/stream` endpoints | No SSE support | **Deferred** | Frontend uses polling | +| i18n | `?locale=` query param | No i18n support | New feature | Add locale handling | + +### 2.2 Field Mapping (Backend → Frontend API) + +The `__yao.member` model already has the necessary fields, with different names: + +| Frontend API | Backend DB (`__yao.member`) | Backend Go (`types.Robot`) | Mapping | +|--------------|----------------------------|---------------------------|---------| +| `member_id` | `member_id` | `MemberID` | Direct | +| `name` | `member_id` | `MemberID` | **Reuse** (slug-like identifier) | +| `display_name` | `display_name` | `DisplayName` | Direct | +| `description` | `bio` | Need to add `Bio` field | Map in OpenAPI layer | +| `email` | `robot_email` | `RobotEmail` | Direct | + +**Required Backend Changes:** +1. Add `Bio` field to `types.Robot` struct +2. Add `bio` to `cache/load.go` memberFields + +### 2.3 Type Differences + +| Frontend Type | Backend Type | Solution | +|---------------|--------------|----------| +| `RobotState.name` | `Robot.MemberID` | Map `member_id` to `name` | +| `RobotState.description` | `Robot.Bio` (new) | Add field, map to `description` | +| `Execution.name` | Not in `types.Execution` | Derive from goals or input in OpenAPI layer | +| `Execution.current_task_name` | Not in `types.Execution` | Derive from current task in OpenAPI layer | +| `ResultFile` | No equivalent | New type in OpenAPI layer (derive from delivery) | +| `Activity` | No equivalent | New type in OpenAPI layer (derive from executions) | + +--- + +## 3. API Endpoints + +> **Base Path:** `/v1/agent/robots` + +### 3.1 Robot Management + +| Method | Path | Handler | Description | +|--------|------|---------|-------------| +| GET | /v1/agent/robots | `ListRobots` | List all robots | +| GET | /v1/agent/robots/:id | `GetRobot` | Get robot details | +| POST | /v1/agent/robots | `CreateRobot` | Create robot | +| PUT | /v1/agent/robots/:id | `UpdateRobot` | Update robot | +| DELETE | /v1/agent/robots/:id | `DeleteRobot` | Delete robot | + +### 3.2 Execution Management + +| Method | Path | Handler | Description | +|--------|------|---------|-------------| +| GET | /v1/agent/robots/:id/executions | `ListExecutions` | List executions | +| GET | /v1/agent/robots/:id/executions/:exec_id | `GetExecution` | Get execution detail | +| POST | /v1/agent/robots/:id/trigger | `TriggerRobot` | Trigger execution (SSE) | +| POST | /v1/agent/robots/:id/intervene | `InterveneRobot` | Intervene execution (SSE) | +| POST | /v1/agent/robots/:id/executions/:exec_id/pause | `PauseExecution` | Pause execution | +| POST | /v1/agent/robots/:id/executions/:exec_id/resume | `ResumeExecution` | Resume execution | +| POST | /v1/agent/robots/:id/executions/:exec_id/cancel | `CancelExecution` | Cancel execution | +| POST | /v1/agent/robots/:id/executions/:exec_id/retry | `RetryExecution` | Retry execution | + +### 3.3 Results Management + +| Method | Path | Handler | Description | +|--------|------|---------|-------------| +| GET | /v1/agent/robots/:id/results | `ListResults` | List deliverables | +| GET | /v1/agent/robots/:id/results/:result_id | `GetResult` | Get deliverable detail | + +### 3.4 Activities & Real-time + +| Method | Path | Handler | Description | +|--------|------|---------|-------------| +| GET | /v1/agent/robots/activities | `ListActivities` | List recent activities | +| GET | /v1/agent/robots/stream | `StreamRobots` | Robot status SSE | +| GET | /v1/agent/robots/:id/executions/:exec_id/stream | `StreamExecution` | Execution progress SSE | + +--- + +## 4. Response Types + +### 4.1 RobotResponse (for list and detail) + +```go +// RobotResponse - formatted robot for API response +// Maps backend fields to frontend expected format +type RobotResponse struct { + MemberID string `json:"member_id"` + TeamID string `json:"team_id"` + Name string `json:"name"` // From Robot.MemberID (slug-like identifier) + DisplayName string `json:"display_name"` // From Robot.DisplayName + Description string `json:"description,omitempty"` // From Robot.Bio + Status string `json:"status"` // idle | working | paused | error | maintenance + Running int `json:"running"` // Current running count + MaxRunning int `json:"max_running"` // From Config.Quota.Max + LastRun *string `json:"last_run,omitempty"` // ISO timestamp + NextRun *string `json:"next_run,omitempty"` // ISO timestamp + RunningIDs []string `json:"running_ids,omitempty"` // Execution IDs + Config *ConfigResponse `json:"config,omitempty"` // Full config (for detail) +} + +// NewRobotResponse converts backend Robot to API response +func NewRobotResponse(robot *types.Robot) *RobotResponse { + return &RobotResponse{ + MemberID: robot.MemberID, + TeamID: robot.TeamID, + Name: robot.MemberID, // Use MemberID as unique identifier + DisplayName: robot.DisplayName, + Description: robot.Bio, // Map Bio to Description + Status: string(robot.Status), + // ... other fields + } +} +``` + +### 4.2 ConfigResponse (robot config) + +```go +// ConfigResponse - formatted config for API response +type ConfigResponse struct { + Identity *IdentityConfig `json:"identity,omitempty"` + Clock *ClockConfig `json:"clock,omitempty"` + Events []EventConfig `json:"events,omitempty"` + Quota *QuotaConfig `json:"quota,omitempty"` + Resources *ResourcesConfig `json:"resources,omitempty"` + Delivery *DeliveryConfig `json:"delivery,omitempty"` + Triggers *TriggersConfig `json:"triggers,omitempty"` + Learn *LearnConfig `json:"learn,omitempty"` + Executor *ExecutorConfig `json:"executor,omitempty"` +} +``` + +### 4.3 ExecutionResponse + +```go +// ExecutionResponse - formatted execution for API response +type ExecutionResponse struct { + ID string `json:"id"` + MemberID string `json:"member_id"` + TeamID string `json:"team_id"` + TriggerType string `json:"trigger_type"` + StartTime string `json:"start_time"` + EndTime *string `json:"end_time,omitempty"` + Status string `json:"status"` + Phase string `json:"phase"` + Error *string `json:"error,omitempty"` + JobID string `json:"job_id"` + Name string `json:"name,omitempty"` // Localized execution name + CurrentTaskName string `json:"current_task_name,omitempty"` // Localized current task + Goals *GoalsResponse `json:"goals,omitempty"` + Tasks []TaskResponse `json:"tasks,omitempty"` + Current *CurrentState `json:"current,omitempty"` + Delivery *DeliveryResult `json:"delivery,omitempty"` +} +``` + +### 4.4 ResultResponse + +```go +// ResultResponse - deliverable file for Results tab +type ResultResponse struct { + ID string `json:"id"` + MemberID string `json:"member_id"` + ExecutionID string `json:"execution_id"` + Name string `json:"name"` + Type string `json:"type"` // pdf, xlsx, csv, json, md + Size int64 `json:"size"` // bytes + CreatedAt string `json:"created_at"` + TriggerType string `json:"trigger_type,omitempty"` + ExecutionName string `json:"execution_name,omitempty"` +} +``` + +### 4.5 ActivityResponse + +```go +// ActivityResponse - activity item +type ActivityResponse struct { + ID string `json:"id"` + Type string `json:"type"` // completed | file | error | started | paused + MemberID string `json:"member_id"` + RobotName string `json:"robot_name"` // Localized + Title string `json:"title"` // Localized + Description string `json:"description,omitempty"` // Localized + FileID string `json:"file_id,omitempty"` + Timestamp string `json:"timestamp"` +} +``` + +--- + +## 5. Request Types + +### 5.1 CreateRobotRequest + +```go +// CreateRobotRequest - create robot request +type CreateRobotRequest struct { + Locale string `json:"locale,omitempty"` // zh-CN | en-US + Name string `json:"name"` // Unique identifier + DisplayName string `json:"display_name"` // Display name + Email string `json:"email,omitempty"` // Robot email + ManagerID string `json:"manager_id,omitempty"` // Manager user ID + WorkMode string `json:"work_mode"` // autonomous | on-demand + Identity *IdentityConfig `json:"identity"` + Resources *ResourcesConfig `json:"resources,omitempty"` +} +``` + +### 5.2 UpdateRobotRequest + +```go +// UpdateRobotRequest - update robot request +type UpdateRobotRequest struct { + Locale string `json:"locale,omitempty"` + DisplayName *string `json:"display_name,omitempty"` + Config *ConfigResponse `json:"config,omitempty"` // Partial update supported +} +``` + +### 5.3 TriggerRequest (SSE) + +```go +// TriggerRequest - trigger robot execution +type TriggerRequest struct { + Locale string `json:"locale,omitempty"` + Messages []Message `json:"messages"` + Attachments []Attachment `json:"attachments,omitempty"` +} + +// Message - chat message +type Message struct { + Role string `json:"role"` // user | assistant + Content string `json:"content"` +} + +// Attachment - file attachment +type Attachment struct { + File string `json:"file"` // __yao.attachment://fileID + Name string `json:"name,omitempty"` +} +``` + +### 5.4 InterveneRequest (SSE) + +```go +// InterveneRequest - intervene during execution +type InterveneRequest struct { + Locale string `json:"locale,omitempty"` + ExecutionID string `json:"execution_id"` + Action string `json:"action"` // task.add | goal.adjust | instruct + Messages []Message `json:"messages"` + Priority string `json:"priority,omitempty"` // high | normal | low + Position string `json:"position,omitempty"` // first | last | next | at +} +``` + +--- + +## 6. Deferred Features + +### 6.1 Multi-turn Chat API (Phase 5 - Deferred) + +> **Risk Level:** 🟡 Medium - Requires new stateful component +> **Frontend Fallback:** Single-submit mode (user input → immediate execution) + +The frontend `ChatDrawer` component expects multi-turn conversation before execution: + +``` +User: "Help me analyze competitor pricing" + ↓ +Robot: "Got it. Which competitors?" + ↓ +User: "Focus on Company A and B" + ↓ +Robot: "Understood. Ready to start?" + ↓ +User clicks [Confirm] → Execution starts +``` + +**Current backend behavior:** `Trigger()` immediately submits to execution pool. + +**Deferred implementation:** +``` +POST /v1/agent/robots/:id/chat +{ + "conversation_id": "conv_001", // For continuing conversation + "messages": [{ "role": "user", "content": "..." }] +} + +Response (SSE): +event: message +data: {"role": "assistant", "content": "..."} + +event: state +data: {"conversation_id": "conv_001", "ready_to_execute": false} +``` + +**For now:** Frontend can skip chat flow, directly call `/trigger` with user message. + +### 6.2 SSE Event Bus (Phase 6 - Deferred) + +> **Risk Level:** 🟡 Medium - Requires modification of executor/manager +> **Frontend Fallback:** Polling (GET /executions every 3-5 seconds) + +Real-time status updates via SSE require an event bus integrated with: +- Manager (robot status changes) +- Executor (execution progress) + +**For now:** Frontend uses polling to refresh status. + +--- + +## 7. SSE Events + +### 7.1 Trigger/Intervene SSE Events + +``` +event: received +data: {"message": "Task received, creating execution..."} + +event: execution +data: {"execution_id": "exec_002", "status": "pending"} + +event: message +data: {"role": "assistant", "content": "好的,我开始处理..."} + +event: phase +data: {"phase": "goals", "message": "正在生成目标..."} + +event: complete +data: {"execution_id": "exec_002", "status": "running"} + +event: error +data: {"error": "Something went wrong"} +``` + +### 7.2 Robot Stream SSE Events (Phase 6 - Deferred) + +``` +event: robot_status +data: {"member_id": "robot_001", "status": "working", "running": 1} + +event: execution_start +data: {"member_id": "robot_001", "execution_id": "exec_001", "name": "每日报表生成"} + +event: execution_complete +data: {"member_id": "robot_001", "execution_id": "exec_001", "status": "completed"} + +event: activity +data: {"id": "act_001", "type": "completed", "member_id": "robot_001", ...} +``` + +### 7.3 Execution Stream SSE Events (Phase 6 - Deferred) + +``` +event: phase +data: {"phase": "tasks", "progress": "2/5 tasks"} + +event: task_start +data: {"task_id": "task_002", "order": 2} + +event: task_complete +data: {"task_id": "task_002", "status": "completed"} + +event: message +data: {"role": "assistant", "content": "正在分析数据..."} + +event: delivery +data: {"summary": "...", "attachments": [...]} + +event: complete +data: {"status": "completed"} + +event: error +data: {"error": "Something went wrong", "phase": "run"} +``` + +--- + +## 8. i18n Support + +### 8.1 Locale Detection + +Priority order: +1. Query parameter: `?locale=zh-CN` +2. Request body field: `locale: "zh-CN"` +3. Accept-Language header +4. Default: `en-US` + +### 8.2 Localized Fields + +| Response Type | Localized Fields | +|---------------|------------------| +| RobotResponse | display_name, description | +| ExecutionResponse | name, current_task_name | +| TaskResponse | (none - tasks use executor_id) | +| ResultResponse | name, execution_name | +| ActivityResponse | robot_name, title, description | + +--- + +## 9. Authentication & Authorization + +### 9.1 Guard Middleware + +All endpoints require OAuth2 authentication via `oauth.Guard` middleware. + +```go +// In router registration +router.Use(oauth.Guard()) +``` + +### 9.2 Permission Checks + +| Endpoint | Required Scope | +|----------|----------------| +| GET /robots | `robots:read` | +| POST /robots | `robots:write` | +| PUT/DELETE /robots/:id | `robots:write` + ownership check | +| Trigger/Intervene | `robots:execute` | +| Stream endpoints | `robots:read` | + +### 9.3 Team Isolation + +Robots are team-scoped. Users can only access robots in their team. + +```go +func checkTeamAccess(ctx context.Context, memberID string) error { + auth := oauth.GetAuthorized(ctx) + robot, _ := robotapi.Get(memberID) + if robot.TeamID != auth.TeamID { + return errors.New("access denied") + } + return nil +} +``` + +--- + +## 10. File Structure + +### 10.1 Backend Store + API Layers + +``` +yao/agent/robot/ +├── store/ # Store Layer (Core CRUD) +│ ├── store.go # Common interfaces +│ ├── execution.go # ExecutionStore (EXISTS) +│ └── robot.go # RobotStore (NEW) +│ +├── api/ # API Layer (Thin wrappers) +│ ├── robot.go # Get, List, Create, Update, Remove +│ ├── execution.go # Execution management +│ ├── trigger.go # Trigger, Intervene +│ ├── results.go # ListResults, GetResult (NEW) +│ └── activities.go # ListActivities (NEW) +│ +├── types/ # Type definitions +│ └── robot.go # Add Bio field +│ +└── cache/ # Cache Layer + └── load.go # Add bio to memberFields +``` + +### 10.2 OpenAPI Layer + +**Decision: Sub-package under `openapi/agent/`** + +Robot logic is complex enough to warrant its own package. This keeps code organized and follows the pattern used by other complex modules. + +``` +yao/openapi/agent/ +├── agent.go # Main route registration (MODIFY: add robot.Attach) +├── assistant.go # Assistant handlers (existing) +├── filter.go # Query filtering (existing) +├── models.go # LLM models (existing) +├── types.go # Types (existing) +│ +└── robot/ # Robot sub-package (NEW) + ├── DESIGN.md # This document ✅ + ├── TODO.md # Implementation plan ✅ + ├── GAPS.md # Gap analysis ✅ + │ + ├── robot.go # Route registration (Attach function) + ├── types.go # Request/Response types + │ + ├── list.go # GET /v1/agent/robots + ├── detail.go # GET/POST/PUT/DELETE /v1/agent/robots/:id + │ + ├── execution.go # Execution list/detail/control handlers + ├── trigger.go # POST /trigger, POST /intervene (SSE) + │ + ├── results.go # GET /results, GET /results/:id + ├── activities.go # GET /activities + │ + ├── stream.go # GET /stream, GET /executions/:id/stream (SSE) + │ + ├── filter.go # Query param parsing helpers + └── utils.go # Locale, time formatting utilities +``` + +**Route Registration (in `openapi/agent/agent.go`):** + +```go +import "github.com/yaoapp/yao/openapi/agent/robot" + +func Attach(group *gin.RouterGroup, oauth types.OAuth) { + group.Use(oauth.Guard) + + // Assistant routes (existing) + group.GET("/assistants", ListAssistants) + group.POST("/assistants", CreateAssistant) + // ... + + // Robot routes (NEW) + robot.Attach(group.Group("/robots"), oauth) +} +``` + +**Robot Route Registration (`robot/robot.go`):** + +```go +package robot + +func Attach(group *gin.RouterGroup, oauth types.OAuth) { + // Robot CRUD + group.GET("", ListRobots) + group.POST("", CreateRobot) + group.GET("/:id", GetRobot) + group.PUT("/:id", UpdateRobot) + group.DELETE("/:id", DeleteRobot) + + // Activities (before :id to avoid conflict) + group.GET("/activities", ListActivities) + group.GET("/stream", StreamRobots) + + // Execution management + group.GET("/:id/executions", ListExecutions) + group.GET("/:id/executions/:exec_id", GetExecution) + group.GET("/:id/executions/:exec_id/stream", StreamExecution) + group.POST("/:id/executions/:exec_id/pause", PauseExecution) + group.POST("/:id/executions/:exec_id/resume", ResumeExecution) + group.POST("/:id/executions/:exec_id/cancel", CancelExecution) + group.POST("/:id/executions/:exec_id/retry", RetryExecution) + + // Trigger & Intervene (SSE) + group.POST("/:id/trigger", TriggerRobot) + group.POST("/:id/intervene", InterveneRobot) + + // Results + group.GET("/:id/results", ListResults) + group.GET("/:id/results/:result_id", GetResult) +} +``` + +--- + +## 11. Error Handling + +### 11.1 Error Response Format + +```json +{ + "error": { + "code": "ROBOT_NOT_FOUND", + "message": "Robot not found", + "details": { + "member_id": "robot_001" + } + } +} +``` + +### 11.2 Error Codes + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ROBOT_NOT_FOUND | 404 | Robot does not exist | +| EXECUTION_NOT_FOUND | 404 | Execution does not exist | +| ROBOT_BUSY | 409 | Robot at max capacity | +| TRIGGER_DISABLED | 403 | Trigger type disabled | +| EXECUTION_NOT_RUNNING | 400 | Cannot pause/resume non-running execution | +| INVALID_REQUEST | 400 | Request validation failed | +| UNAUTHORIZED | 401 | Not authenticated | +| FORBIDDEN | 403 | No permission | + +--- + +## 12. Implementation Notes + +### 12.1 Backend Architecture: Store + API Layers + +> **Principle:** Store layer handles database CRUD, API layer handles business logic. +> This enables reuse across Golang API, JSAPI, and Yao Process. + +``` +Consumers (Golang API / JSAPI / Yao Process) + │ + ▼ +┌─────────────────────────────────────────┐ +│ API Layer (robot/api/) │ +│ Thin wrappers: validation, cache ops │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Store Layer (robot/store/) │ +│ Core CRUD: RobotStore, ExecutionStore │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Model Layer (__yao.member) │ +└─────────────────────────────────────────┘ +``` + +### 12.2 Store Layer Extensions + +**File: `store/robot.go` (NEW)** - Core Robot CRUD + +```go +type RobotStore struct { + modelID string // "__yao.member" +} + +func (s *RobotStore) Save(ctx context.Context, record *RobotRecord) error +func (s *RobotStore) Get(ctx context.Context, memberID string) (*RobotRecord, error) +func (s *RobotStore) List(ctx context.Context, opts *ListOptions) ([]*RobotRecord, error) +func (s *RobotStore) Delete(ctx context.Context, memberID string) error +func (s *RobotStore) UpdateConfig(ctx context.Context, memberID string, config map[string]interface{}) error +``` + +**File: `store/execution.go` (extend)** + +```go +func (s *ExecutionStore) ListResults(ctx context.Context, memberID string, opts *ResultsQuery) ([]*ResultRecord, error) +func (s *ExecutionStore) GetResult(ctx context.Context, resultID string) (*ResultRecord, error) +func (s *ExecutionStore) ListActivities(ctx context.Context, opts *ActivityQuery) ([]*ActivityRecord, error) +``` + +### 12.3 API Layer Extensions + +**File: `api/robot.go` (extend)** - Thin wrappers + +```go +// Create - calls store.RobotStore.Save() + cache refresh +func Create(ctx *types.Context, teamID string, req *CreateRobotRequest) (*types.Robot, error) + +// Update - calls store.RobotStore.UpdateConfig() + cache refresh +func Update(ctx *types.Context, memberID string, req *UpdateRobotRequest) (*types.Robot, error) + +// Remove - calls store.RobotStore.Delete() + cache invalidate +func Remove(ctx *types.Context, memberID string) error +``` + +**File: `api/results.go` (NEW)** + +```go +func ListResults(ctx *types.Context, memberID string, query *ResultQuery) (*ResultsResult, error) +func GetResult(ctx *types.Context, resultID string) (*ResultFile, error) +``` + +**File: `api/activities.go` (NEW)** + +```go +func ListActivities(ctx *types.Context, query *ActivityQuery) (*ActivitiesResult, error) +``` + +### 12.4 Localization + +Add `Locale` parameter support for localized responses. + +### 12.5 SSE Implementation + +Use standard Go SSE pattern: + +```go +func streamHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, _ := w.(http.Flusher) + + for event := range events { + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, event.Data) + flusher.Flush() + } +} +``` + +### 12.6 Localization Strategy + +- Store display names in `__yao.member.display_name` (single language) initially +- Future: Add `display_name_cn`, `display_name_en` or use JSON `{"en": "...", "cn": "..."}` +- Execution names derived from goals or input message +- Activities derive titles from execution data + +--- + +## 13. API Base Path Decision + +Based on analysis of existing `openapi/` structure: + +| Option | Path | Pros | Cons | +|--------|------|------|------| +| ❌ A | `/v1/robots` | Shorter path | New namespace, inconsistent | +| ✅ B | `/v1/agent/robots` | Groups with agent APIs, consistent | Longer path | +| ❌ C | `/v1/members?type=robot` | Uses existing members | Less intuitive | + +**Decision**: Use `/v1/agent/robots` as base path. + +**Rationale:** +1. `openapi/agent/` already exists with `/v1/agent/assistants` +2. Robot is conceptually an Agent type (Autonomous Robot Agent) +3. Follows the established pattern: `/v1/agent/{agent-type}` +4. Keeps agent-related APIs logically grouped + +**Frontend Impact:** +- Update `cui/packages/cui/pages/mission-control/API.md` base path from `/v1/robots` to `/v1/agent/robots` +- Minimal code change (just update base URL constant) + +--- + +## 14. References + +- Frontend API Requirements: `cui/packages/cui/pages/mission-control/API.md` +- Backend Robot Design: `yao/agent/robot/DESIGN.md` +- Backend Technical Spec: `yao/agent/robot/TECHNICAL.md` +- Existing OpenAPI Patterns: `yao/openapi/kb/`, `yao/openapi/chat/` diff --git a/openapi/agent/robot/GAPS.md b/openapi/agent/robot/GAPS.md new file mode 100644 index 00000000..209dfecd --- /dev/null +++ b/openapi/agent/robot/GAPS.md @@ -0,0 +1,846 @@ +# Robot OpenAPI - Gap Analysis + +> This document analyzes the gaps between existing backend implementation and frontend API requirements. +> Generated from reviewing: `yao/agent/robot/`, `yao/openapi/agent/`, `cui/packages/cui/pages/mission-control/` + +--- + +## Summary + +| Category | Risk | Status | Items to Implement | +|----------|------|--------|-------------------| +| Backend Types | 🟢 Low | 🟡 Partial | 1 field to add (`Bio`), 2 fields for Execution | +| Backend Cache | 🟢 Low | 🟡 Partial | Add `bio` to memberFields in `cache/load.go` | +| Backend API | 🟢 Low | 🟡 Partial | 7 functions missing (CRUD + Results + Activities) | +| OpenAPI Layer | 🟢 Low | ⬜ New | 19 endpoints, response type mapping | +| i18n | 🟢 Low | ⬜ New | Locale parameter support | +| **Chat API** | 🟡 Medium | ⬜ Deferred | Multi-turn conversation (frontend fallback: single-submit) | +| **SSE Infrastructure** | 🟡 Medium | ⬜ Deferred | Event bus + SSE handlers (frontend fallback: polling) | + +### Key Field Mapping (Backend → Frontend) + +| Frontend API | Backend DB (`__yao.member`) | Backend Go (`types.Robot`) | +|--------------|----------------------------|---------------------------| +| `name` | `member_id` | `MemberID` | +| `display_name` | `display_name` | `DisplayName` | +| `description` | `bio` | Need to add `Bio` field | +| `email` | `robot_email` | `RobotEmail` | + +--- + +## 1. Backend Types Gaps (`yao/agent/robot/types/`) + +### 1.1 Field Mapping (Backend → Frontend API) + +The `__yao.member` model already has the necessary fields, but with different names: + +| Frontend API Field | Backend DB Field | Status | Notes | +|-------------------|------------------|--------|-------| +| `member_id` | `member_id` | ✅ Exists | Global unique identifier | +| `name` | `member_id` | ✅ **Reuse** | Frontend expects a slug like `sales-analyst`, can use `member_id` | +| `display_name` | `display_name` | ✅ Exists | Localized display name | +| `description` | `bio` | ✅ Exists | `bio` field in `__yao.member` is the robot description | + +**Backend Robot struct (`types/robot.go`):** +```go +type Robot struct { + MemberID string `json:"member_id"` // ✅ Exists + TeamID string `json:"team_id"` // ✅ Exists + DisplayName string `json:"display_name"` // ✅ Exists + SystemPrompt string `json:"system_prompt"`// ✅ Exists + // ... +} +``` + +**Missing fields to add to Robot struct:** +```go +type Robot struct { + // ... existing fields ... + Bio string `json:"bio"` // NEW: from __yao.member.bio (robot description) +} +``` + +**OpenAPI Response Mapping:** +```go +// In OpenAPI layer, map backend fields to frontend expected format +type RobotResponse struct { + MemberID string `json:"member_id"` + Name string `json:"name"` // Use MemberID as unique slug + DisplayName string `json:"display_name"` + Description string `json:"description"` // Map from Robot.Bio + // ... +} +``` + +### 1.2 Cache/Load Update Needed + +Update `cache/load.go` to fetch `bio` field: + +```go +var memberFields = []interface{}{ + "id", + "member_id", + "team_id", + "display_name", + "bio", // ADD THIS + "system_prompt", + "robot_status", + "autonomous_mode", + "robot_config", + "robot_email", // Already there +} +``` + +### 1.3 Missing Fields in `Execution` struct + +| Field | Type | Location | Description | +|-------|------|----------|-------------| +| `Name` | `string` | `types/robot.go` | Derived from goals or human input, for UI display | +| `CurrentTaskName` | `string` | `types/robot.go` | What the agent is doing RIGHT NOW | + +**Required (add to Execution struct):** +```go +type Execution struct { + // ... existing fields ... + Name string `json:"name,omitempty"` // NEW: execution name for UI + CurrentTaskName string `json:"current_task_name,omitempty"` // NEW: current task description +} +``` + +> **Note:** These can be derived in the OpenAPI layer from existing fields: +> - `Name`: Derive from `Goals.Content` first line or `Input.Messages[0].Content` +> - `CurrentTaskName`: Derive from `Current.Task` executor info or progress + +### 1.4 New Types Needed + +#### Activity Type (for Activity API) + +> **Note:** Activity can be derived from execution history without new storage. +> These types go in OpenAPI response layer, not core types. + +```go +// openapi/agent/robot/types.go (API response types) + +// ActivityType - activity type enum +type ActivityType string + +const ( + ActivityCompleted ActivityType = "completed" + ActivityFile ActivityType = "file" + ActivityError ActivityType = "error" + ActivityStarted ActivityType = "started" + ActivityPaused ActivityType = "paused" +) + +// ActivityResponse - activity item for UI +type ActivityResponse struct { + ID string `json:"id"` + Type ActivityType `json:"type"` + MemberID string `json:"member_id"` + RobotName string `json:"robot_name"` // Localized + Title string `json:"title"` // Localized + Description string `json:"description,omitempty"` // Localized + FileID string `json:"file_id,omitempty"` + Timestamp string `json:"timestamp"` // ISO format +} +``` + +#### ResultFile Type (for Results API) + +> **Note:** Results are derived from `execution.delivery.content.attachments`. +> No separate storage needed. + +```go +// openapi/agent/robot/types.go (API response types) + +// ResultFileResponse - deliverable file for Results Tab +type ResultFileResponse struct { + ID string `json:"id"` // attachment index or file ID + MemberID string `json:"member_id"` + ExecutionID string `json:"execution_id"` + Name string `json:"name"` // From attachment.Title + Type string `json:"type"` // Derived from file extension + Size int64 `json:"size"` // From file system + CreatedAt string `json:"created_at"` // Execution end time + TriggerType string `json:"trigger_type,omitempty"` + ExecutionName string `json:"execution_name,omitempty"` // Derived +} +``` + +--- + +## 2. Multi-turn Conversation Gap (Critical) + +### 2.1 Frontend Expectation + +The frontend `ChatDrawer` component expects **multi-turn conversation** before execution starts: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ASSIGN TASK DRAWER (ChatDrawer) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ User: "Help me analyze competitor pricing" │ +│ ↓ │ +│ Robot: "Got it. Which competitors? Any specific metrics?" │ +│ ↓ │ +│ User: "Focus on Company A and B, compare pricing tiers" │ +│ ↓ │ +│ Robot: "Understood. I'll analyze A and B pricing tiers. │ +│ Ready to start?" │ +│ ↓ │ +│ User clicks [Confirm] → Execution starts │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key Flow:** +1. User sends message → Backend returns assistant response +2. User can continue conversation (refine task) +3. User confirms → Execution actually starts + +### 2.2 Current Backend Implementation + +```go +// api/trigger.go - Current behavior +func Trigger(ctx *types.Context, memberID string, req *TriggerRequest) (*TriggerResult, error) { + // Immediately submits to execution pool + // No conversation state, no confirmation step +} +``` + +**Problem:** Backend triggers execution immediately on first message. No multi-turn conversation support. + +### 2.3 Gap Analysis + +| Feature | Frontend Expects | Backend Has | +|---------|------------------|-------------| +| Multi-turn chat | ✅ Yes | ❌ No | +| Conversation state | ✅ Yes | ❌ No | +| Confirm before execute | ✅ Yes | ❌ No | +| SSE for each message | ✅ Yes | ❌ No | + +### 2.4 Required New API + +**Option A: Chat API (Recommended)** + +``` +POST /v1/agent/robots/:id/chat +``` + +**Request:** +```json +{ + "conversation_id": "conv_001", // Optional, for continuing conversation + "messages": [ + { "role": "user", "content": "Help me analyze competitor pricing" } + ], + "attachments": [] +} +``` + +**Response (SSE):** +``` +event: message +data: {"role": "assistant", "content": "Got it. Which competitors?"} + +event: state +data: {"conversation_id": "conv_001", "ready_to_execute": false} +``` + +**Then Trigger with conversation:** +``` +POST /v1/agent/robots/:id/trigger +{ + "conversation_id": "conv_001", // References chat history + "confirm": true +} +``` + +**Option B: Extend Trigger API** + +Add `confirm` parameter to trigger: +```json +{ + "messages": [...], + "confirm": false // false = chat mode, true = execute +} +``` + +### 2.5 Backend Implementation Needed + +1. **Conversation Store** - Store chat history temporarily + ```go + // store/conversation.go (NEW) + type ConversationStore interface { + Create(memberID string, messages []Message) (conversationID string, error) + Append(conversationID string, messages []Message) error + Get(conversationID string) (*Conversation, error) + Delete(conversationID string) error // Auto-cleanup after execution + } + ``` + +2. **Chat Handler** - Process messages, return assistant response + ```go + // api/chat.go (NEW) + func Chat(ctx *types.Context, memberID string, req *ChatRequest) (*ChatResponse, error) { + // 1. Get or create conversation + // 2. Call LLM for response (using robot's system prompt) + // 3. Store updated conversation + // 4. Return assistant message + conversation_id + } + ``` + +3. **Trigger Extension** - Support conversation_id + ```go + // api/trigger.go (MODIFY) + type TriggerRequest struct { + // ... existing fields ... + ConversationID string `json:"conversation_id,omitempty"` // NEW + } + ``` + +### 2.6 Same for Intervention + +`GuideExecutionDrawer` also uses `ChatDrawer` and expects the same multi-turn behavior for intervention. + +--- + +## 3. Backend Architecture: Store + API Layers + +### 3.1 Architecture Decision + +> **Principle:** Store layer handles database CRUD, API layer handles business logic. +> This enables reuse across Golang API, JSAPI, and Yao Process. + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Consumers │ +├──────────────────────────────────────────────────────────────────────┤ +│ Golang API (robot/api) │ JSAPI (JS Runtime) │ Yao Process │ +└──────────────────────────────┴───────────────────────┴───────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ API Layer (robot/api/) │ +│ Business logic, parameter validation, cache invalidation │ +│ - Thin wrappers that call store layer │ +│ - Reusable across all consumers │ +└──────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ Store Layer (robot/store/) │ +│ Pure database CRUD, no business logic │ +│ - RobotStore: Robot member CRUD (NEW) │ +│ - ExecutionStore: Execution records (EXISTS) │ +└──────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ Model Layer (__yao.member, etc.) │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 Store Layer: Missing Functions + +**File: `store/robot.go` (NEW)** - Core CRUD implementation + +| Function | Status | Description | +|----------|--------|-------------| +| `RobotStore.Save()` | ⬜ Missing | Create or update robot member | +| `RobotStore.Get()` | ⬜ Missing | Get robot by member_id | +| `RobotStore.List()` | ⬜ Missing | List robots with filters | +| `RobotStore.Delete()` | ⬜ Missing | Delete robot member | +| `RobotStore.UpdateConfig()` | ⬜ Missing | Update robot config only | + +**File: `store/execution.go` (extend)** + +| Function | Status | Description | +|----------|--------|-------------| +| `ExecutionStore.ListResults()` | ⬜ Missing | Query deliverables from executions | +| `ExecutionStore.GetResult()` | ⬜ Missing | Get single deliverable | +| `ExecutionStore.ListActivities()` | ⬜ Missing | Derive activities from history | + +### 3.3 API Layer: Missing Functions + +**File: `api/robot.go` (extend)** - Thin wrappers calling store + +| Function | Status | Description | +|----------|--------|-------------| +| `Create()` | ⬜ Missing | Call `store.RobotStore.Save()` + cache refresh | +| `Update()` | ⬜ Missing | Call `store.RobotStore.UpdateConfig()` + cache refresh | +| `Remove()` | ⬜ Missing | Call `store.RobotStore.Delete()` + cache invalidate | + +**File: `api/results.go` (NEW)** - Thin wrappers + +| Function | Status | Description | +|----------|--------|-------------| +| `ListResults()` | ⬜ Missing | Call `store.ExecutionStore.ListResults()` | +| `GetResult()` | ⬜ Missing | Call `store.ExecutionStore.GetResult()` | + +**File: `api/activities.go` (NEW)** - Thin wrappers + +| Function | Status | Description | +|----------|--------|-------------| +| `ListActivities()` | ⬜ Missing | Call `store.ExecutionStore.ListActivities()` | + +**File: `api/execution.go` (extend)** + +| Function | Status | Description | +|----------|--------|-------------| +| `RetryExecution()` | ⬜ Missing | Re-trigger with same input | + +### 3.4 Existing Functions (Already implemented) + +**Store Layer (`store/`):** + +| Function | File | Status | +|----------|------|--------| +| `ExecutionStore.Save()` | `execution.go` | ✅ Exists | +| `ExecutionStore.Get()` | `execution.go` | ✅ Exists | +| `ExecutionStore.List()` | `execution.go` | ✅ Exists | +| `ExecutionStore.Delete()` | `execution.go` | ✅ Exists | +| `ExecutionStore.UpdatePhase()` | `execution.go` | ✅ Exists | +| `ExecutionStore.UpdateStatus()` | `execution.go` | ✅ Exists | + +**API Layer (`api/`):** + +| Function | File | Status | +|----------|------|--------| +| `List()` | `robot.go` | ✅ Exists | +| `Get()` | `robot.go` | ✅ Exists | +| `GetStatus()` | `robot.go` | ✅ Exists | +| `Trigger()` | `trigger.go` | ✅ Exists | +| `Intervene()` | `trigger.go` | ✅ Exists | +| `GetExecutions()` | `execution.go` | ✅ Exists | +| `GetExecution()` | `execution.go` | ✅ Exists | +| `PauseExecution()` | `execution.go` | ✅ Exists | +| `ResumeExecution()` | `execution.go` | ✅ Exists | +| `StopExecution()` | `execution.go` | ✅ Exists | + +### 3.5 Code Examples + +**Store Layer (`store/robot.go`):** +```go +// RobotStore - persistent storage for robot members +type RobotStore struct { + modelID string +} + +func NewRobotStore() *RobotStore { + return &RobotStore{modelID: "__yao.member"} +} + +// Save creates or updates a robot member record +func (s *RobotStore) Save(ctx context.Context, record *RobotRecord) error + +// Get retrieves a robot by member_id +func (s *RobotStore) Get(ctx context.Context, memberID string) (*RobotRecord, error) + +// List retrieves robots with filters +func (s *RobotStore) List(ctx context.Context, opts *ListOptions) ([]*RobotRecord, error) + +// Delete removes a robot member +func (s *RobotStore) Delete(ctx context.Context, memberID string) error +``` + +**API Layer (`api/robot.go`):** +```go +// Create creates a new robot member (thin wrapper) +func Create(ctx *types.Context, teamID string, req *CreateRobotRequest) (*types.Robot, error) { + // 1. Validate request + // 2. Call store.RobotStore.Save() + // 3. Refresh cache + // 4. Return robot +} + +// Update updates robot config (thin wrapper) +func Update(ctx *types.Context, memberID string, req *UpdateRobotRequest) (*types.Robot, error) { + // 1. Validate request + // 2. Call store.RobotStore.UpdateConfig() + // 3. Refresh cache + // 4. Return updated robot +} + +// Remove deletes a robot member (thin wrapper) +func Remove(ctx *types.Context, memberID string) error { + // 1. Check permissions + // 2. Call store.RobotStore.Delete() + // 3. Invalidate cache +} +``` + +--- + +## 4. OpenAPI Layer (`yao/openapi/agent/robot/`) + +### 4.1 Files to Create + +``` +yao/openapi/agent/robot/ +├── DESIGN.md # ✅ Exists +├── TODO.md # ✅ Exists +├── GAPS.md # ✅ This file +│ +├── robot.go # Route registration +├── types.go # Request/Response types +├── list.go # GET /v1/agent/robots +├── detail.go # GET/POST/PUT/DELETE /v1/agent/robots/:id +├── execution.go # Execution list/detail/control +├── trigger.go # Trigger/Intervene (SSE) +├── results.go # Results endpoints +├── activities.go # Activities endpoint +├── stream.go # Real-time SSE streams +├── filter.go # Query param parsing +└── utils.go # Locale, time formatting +``` + +### 4.2 Endpoints to Implement + +#### Robot CRUD (5 endpoints) + +| Endpoint | Handler | Backend API | +|----------|---------|-------------| +| `GET /robots` | `ListRobots` | `api.List()` ✅ | +| `GET /robots/:id` | `GetRobot` | `api.Get()` + `api.GetStatus()` ✅ | +| `POST /robots` | `CreateRobot` | `api.Create()` ⬜ | +| `PUT /robots/:id` | `UpdateRobot` | `api.Update()` ⬜ | +| `DELETE /robots/:id` | `DeleteRobot` | `api.Remove()` ⬜ | + +#### Chat & Execution Management (9 endpoints) + +| Endpoint | Handler | Backend API | +|----------|---------|-------------| +| `POST /robots/:id/chat` | `ChatWithRobot` | `api.Chat()` ⬜ **NEW - Multi-turn conversation** | +| `GET /robots/:id/executions` | `ListExecutions` | `api.GetExecutions()` ✅ | +| `GET /robots/:id/executions/:exec_id` | `GetExecution` | `api.GetExecution()` ✅ | +| `POST /robots/:id/trigger` | `TriggerRobot` | `api.Trigger()` ✅ (needs conversation_id support) | +| `POST /robots/:id/intervene` | `InterveneRobot` | `api.Intervene()` ✅ (needs conversation_id support) | +| `POST /robots/:id/executions/:exec_id/pause` | `PauseExecution` | `api.PauseExecution()` ✅ | +| `POST /robots/:id/executions/:exec_id/resume` | `ResumeExecution` | `api.ResumeExecution()` ✅ | +| `POST /robots/:id/executions/:exec_id/cancel` | `CancelExecution` | `api.StopExecution()` ✅ | +| `POST /robots/:id/executions/:exec_id/retry` | `RetryExecution` | `api.RetryExecution()` ⬜ | + +#### Results (2 endpoints) + +| Endpoint | Handler | Backend API | +|----------|---------|-------------| +| `GET /robots/:id/results` | `ListResults` | `api.ListResults()` ⬜ | +| `GET /robots/:id/results/:result_id` | `GetResult` | `api.GetResult()` ⬜ | + +#### Activities (1 endpoint) + +| Endpoint | Handler | Backend API | +|----------|---------|-------------| +| `GET /robots/activities` | `ListActivities` | `api.ListActivities()` ⬜ | + +#### SSE Streams (3 endpoints) + +| Endpoint | Handler | Backend Event Bus | +|----------|---------|-------------------| +| `GET /robots/stream` | `StreamRobots` | ⬜ New event bus needed | +| `GET /robots/:id/executions/:exec_id/stream` | `StreamExecution` | ⬜ New event bus needed | +| `POST /robots/:id/trigger` (SSE) | `TriggerRobot` | Wrap existing `api.Trigger()` | +| `POST /robots/:id/intervene` (SSE) | `InterveneRobot` | Wrap existing `api.Intervene()` | + +--- + +## 5. SSE Infrastructure Gaps + +### 5.1 Event Bus Needed + +The backend needs an event bus to publish real-time events. Currently, the robot module doesn't have one. + +**Required Components:** + +```go +// robot/events/bus.go (NEW PACKAGE) + +type EventBus struct { + subscribers map[string][]chan Event + mu sync.RWMutex +} + +type Event struct { + Type string `json:"type"` // robot_status, execution_start, etc. + Payload interface{} `json:"payload"` +} + +func (bus *EventBus) Publish(event Event) +func (bus *EventBus) Subscribe(topic string) <-chan Event +func (bus *EventBus) Unsubscribe(topic string, ch <-chan Event) +``` + +### 5.2 Event Publishers Needed + +| Event | Source | When | +|-------|--------|------| +| `robot_status` | Manager | Robot status changes | +| `execution_start` | Executor | Execution begins | +| `execution_complete` | Executor | Execution ends | +| `phase` | Executor | Phase changes | +| `task_start` | Runner | Task begins | +| `task_complete` | Runner | Task ends | +| `activity` | Multiple | Any activity event | + +### 5.3 Integration Points + +**In `manager/manager.go`:** +```go +// Publish when robot status changes +eventBus.Publish(Event{Type: "robot_status", Payload: ...}) +``` + +**In `executor/standard/executor.go`:** +```go +// Publish when execution starts/ends +eventBus.Publish(Event{Type: "execution_start", Payload: ...}) +``` + +--- + +## 6. i18n Support Gaps + +### 6.1 Current State + +- No locale parameter in backend API +- No localization infrastructure + +### 6.2 Required Changes + +**Add locale to context:** +```go +// types/context.go +type Context struct { + context.Context + Auth *types.AuthorizedInfo + MemberID string + Locale string // NEW: "zh-CN" | "en-US" +} +``` + +**Add locale helper:** +```go +// utils/locale.go (NEW) +func GetLocale(r *http.Request) string +func Localize(key, locale string) string +``` + +**Localized fields:** +- `RobotState.display_name` +- `RobotState.description` +- `Execution.name` +- `Execution.current_task_name` +- `ResultFile.name` +- `ResultFile.execution_name` +- `Activity.robot_name` +- `Activity.title` +- `Activity.description` + +--- + +## 7. Data Source Gaps + +### 7.1 Results Data + +Results are derived from execution delivery data. Need to: + +1. **Query from `store/execution.go`** - executions with delivery attachments +2. **Extract attachment metadata** - file ID, name, type, size + +**Implementation:** +```go +// store/results.go (NEW) +func (s *ExecutionStore) ListResults(ctx context.Context, memberID string, opts *ResultsQuery) ([]*ResultFile, int, error) { + // Query executions with delivery.content.attachments + // Extract and format as ResultFile +} +``` + +### 7.2 Activities Data + +Activities can be derived from: +1. **Job system logs** - existing `job.ListLogs()` +2. **Execution state changes** - from `store/execution.go` + +**Implementation Options:** + +**Option A: Derive from execution history** +```go +func ListActivities(ctx context.Context, query *ActivityQuery) ([]*Activity, error) { + // Query recent executions + // Map to Activity based on status changes +} +``` + +**Option B: Separate activity log (recommended for real-time)** +```go +// New table: __yao.robot_activity +type ActivityRecord struct { + ID int64 + Type ActivityType + MemberID string + ExecutionID string + Data JSON + Timestamp time.Time +} +``` + +--- + +## 8. Implementation Priority + +> **Strategy:** Low-risk phases first. Medium-risk features (Chat API, SSE) can be deferred. +> Frontend can use polling and single-submit mode as fallback. + +--- + +### 🟢 Phase 1: Core CRUD [Low Risk] + +1. ⬜ Add `Bio` field to `Robot` struct (`types/robot.go`) +2. ⬜ Add `bio` to `memberFields` in `cache/load.go` +3. ⬜ Implement `api.Create()`, `api.Update()`, `api.Remove()` +4. ⬜ Create OpenAPI handlers: list, detail, create, update, delete +5. ⬜ Add response type mapping (`name` ← `member_id`, `description` ← `bio`) + +### 🟢 Phase 2: Execution Management [Low Risk] + +1. ⬜ Add derived fields in OpenAPI layer (`name`, `current_task_name`) +2. ⬜ Implement `api.RetryExecution()` +3. ⬜ Create OpenAPI handlers: execution list, detail, control +4. ⬜ Wrap trigger/intervene (single-submit mode, no chat) + +### 🟢 Phase 3: Results & Activities [Low Risk] + +1. ⬜ Create `ActivityResponse` and `ResultFileResponse` types in OpenAPI layer +2. ⬜ Implement `api.ListResults()`, `api.GetResult()` (derive from executions) +3. ⬜ Implement `api.ListActivities()` (derive from execution history) +4. ⬜ Create OpenAPI handlers + +### 🟢 Phase 4: i18n [Low Risk] + +1. ⬜ Add `Locale` to context +2. ⬜ Add locale helper functions +3. ⬜ Implement localized response fields + +--- + +### 🟡 Phase 5: Multi-turn Chat API [Medium Risk - Deferred] + +> **Fallback:** Frontend uses single-submit mode (user input → immediate execution) + +1. ⬜ Create `store/conversation.go` - temporary conversation storage +2. ⬜ Create `api/chat.go` - chat handler with LLM call +3. ⬜ Extend `api/trigger.go` - support `conversation_id` +4. ⬜ Create OpenAPI endpoint: `POST /robots/:id/chat` (SSE) +5. ⬜ Update `POST /robots/:id/trigger` to accept conversation reference +6. ⬜ Same for `POST /robots/:id/intervene` + +### 🟡 Phase 6: Real-time SSE [Medium Risk - Deferred] + +> **Fallback:** Frontend uses polling (GET /executions every 3-5s) + +1. ⬜ Create event bus package +2. ⬜ Integrate event publishing in manager/executor +3. ⬜ Implement SSE stream handlers +4. ⬜ End-to-end testing + +--- + +## 9. Testing Strategy + +### Unit Tests + +- `types/activity_test.go` - new types +- `types/result_test.go` - new types +- `api/robot_test.go` - CRUD functions +- `api/results_test.go` - results API +- `api/activities_test.go` - activities API + +### Integration Tests + +- `openapi/agent/robot/*_test.go` - HTTP endpoint tests +- `openapi/agent/robot/sse_test.go` - SSE stream tests + +### E2E Tests + +- Full flow: create robot → trigger → stream events → get results + +--- + +## 10. Files to Modify Summary + +### Backend (`yao/agent/robot/`) + +#### Store Layer (Core CRUD - implement first) + +| File | Action | Changes | +|------|--------|---------| +| `store/robot.go` | **Create** | `RobotStore` - Robot member CRUD (Save, Get, List, Delete, UpdateConfig) | +| `store/execution.go` | Modify | Add `ListResults()`, `GetResult()`, `ListActivities()` | +| `store/conversation.go` | Create | Temporary conversation storage (Phase 5 - Deferred) | + +#### Types Layer + +| File | Action | Changes | +|------|--------|---------| +| `types/robot.go` | Modify | Add `Bio` field | +| `types/conversation.go` | Create | `Conversation`, `ChatRequest`, `ChatResponse` types (Phase 5) | +| `types/context.go` | Modify | Add `Locale` field | + +#### Cache Layer + +| File | Action | Changes | +|------|--------|---------| +| `cache/load.go` | Modify | Add `bio` to `memberFields` slice | + +#### API Layer (Thin wrappers calling store) + +| File | Action | Changes | +|------|--------|---------| +| `api/robot.go` | Modify | Add `Create()`, `Update()`, `Remove()` - call store.RobotStore | +| `api/results.go` | Create | `ListResults()`, `GetResult()` - call store.ExecutionStore | +| `api/activities.go` | Create | `ListActivities()` - call store.ExecutionStore | +| `api/execution.go` | Modify | Add `RetryExecution()` | +| `api/chat.go` | Create | `Chat()` - multi-turn conversation (Phase 5 - Deferred) | +| `api/trigger.go` | Modify | Add `ConversationID` support (Phase 5 - Deferred) | + +#### Events Layer (Phase 6 - Deferred) + +| File | Action | Changes | +|------|--------|---------| +| `events/bus.go` | Create | Event bus for SSE | + +### OpenAPI (`yao/openapi/agent/robot/`) + +| File | Action | Description | +|------|--------|-------------| +| `robot.go` | Create | Route registration | +| `types.go` | Create | Request/Response types | +| `list.go` | Create | List robots handler | +| `detail.go` | Create | Robot CRUD handlers | +| `chat.go` | Create | Multi-turn chat SSE handler | +| `execution.go` | Create | Execution handlers | +| `trigger.go` | Create | Trigger/Intervene SSE (with conversation support) | +| `results.go` | Create | Results handlers | +| `activities.go` | Create | Activities handler | +| `stream.go` | Create | SSE streams | +| `filter.go` | Create | Query parsing | +| `utils.go` | Create | Utilities | + +### Parent (`yao/openapi/agent/`) + +| File | Action | Changes | +|------|--------|---------| +| `agent.go` | Modify | Add `robot.Attach(group.Group("/robots"), oauth)` | + +--- + +## 11. References + +- Frontend API Requirements: `cui/packages/cui/pages/mission-control/API.md` +- Backend Robot Types: `yao/agent/robot/types/` +- Backend Robot API: `yao/agent/robot/api/` +- OpenAPI Design: `yao/openapi/agent/robot/DESIGN.md` +- OpenAPI TODO: `yao/openapi/agent/robot/TODO.md` diff --git a/openapi/agent/robot/TODO.md b/openapi/agent/robot/TODO.md new file mode 100644 index 00000000..c685f0a5 --- /dev/null +++ b/openapi/agent/robot/TODO.md @@ -0,0 +1,816 @@ +# Robot OpenAPI - Implementation TODO + +> Based on: `openapi/agent/robot/DESIGN.md`, `openapi/agent/robot/GAPS.md` +> Depends on: `yao/agent/robot/api/` (Go API layer) +> Base Path: `/v1/agent/robots` + +--- + +## Implementation Strategy + +> **Integrate frontend immediately after each phase to validate deliverables.** +> Frontend has fallback mechanisms (polling, single-submit mode). + +``` +🟢 Phase 1: Core CRUD ✅ + Backend → SDK → Page Integration + └─ List, Get, Create, Update, Delete robots + +✅ Phase 1-FE: Frontend Integration ✅ [Completed] + └─ SDK (openapi/robot.ts) ✅ + └─ 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 + +🟢 Phase 3: Results & Activities + Backend → SDK → Page Integration + └─ List deliverables, Activity feed + +🟢 Phase 4: i18n + Backend → SDK → Page Integration + └─ Locale parameter support + +🟡 Medium Risk (Deferred): + Phase 5: Multi-turn Chat API + Phase 6: Real-time SSE Streams +``` + +--- + +## 🟢 Phase 1: Core CRUD ✅ [Low Risk] + +**Goal:** Basic robot management endpoints +**Risk:** 🟢 Low - All new code, no changes to existing logic +**Status:** ✅ Backend Complete → Proceed to Phase 1.5 Frontend Integration + +### 1.1 Backend Prerequisites ✅ + +#### Types & Cache +- [x] Add `Bio` field to `types.Robot` struct in `yao/agent/robot/types/robot.go` +- [x] Add `bio` to `memberFields` in `yao/agent/robot/cache/load.go` + +#### Store Layer (Core CRUD - implement first) +- [x] Create `store/robot.go` with `RobotStore` struct +- [x] Implement `RobotStore.Save()` - create/update robot member +- [x] Implement `RobotStore.Get()` - get by member_id +- [x] Implement `RobotStore.List()` - list with filters +- [x] Implement `RobotStore.Delete()` - delete robot member +- [x] Implement `RobotStore.UpdateConfig()` - update config only +- [x] Implement `RobotStore.UpdateStatus()` - update status only +- [x] Add Yao permission fields support (`__yao_created_by`, `__yao_team_id`, etc.) +- [x] Add tests: `store/robot_test.go` + +#### 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 +- [x] Add `AuthScope` for Yao permission fields +- [x] Add request/response types in `api/types.go` +- [x] Add tests: `api/robot_test.go` + +#### Utils Layer +- [x] Create `utils/convert.go` with unified type conversion functions +- [x] Implement `To` functions (ToBool, ToInt, ToFloat64, ToTimestamp, ToJSONValue) +- [x] Implement `Get` functions for map value extraction +- [x] Add tests: `utils/convert_test.go` + +### 1.2 OpenAPI Setup ✅ + +- [x] Create `openapi/agent/robot/` directory (sub-package under agent) +- [x] Create `robot.go` - route registration with `Attach()` function +- [x] Register routes in `openapi/agent/agent.go` via `robot.Attach(group.Group("/robots"), oauth)` +- [x] Add OAuth guard middleware + +### 1.3 OpenAPI Types ✅ + +> Note: Core types already exist in `agent/robot/api/types.go`. OpenAPI layer needs HTTP-specific types. + +- [x] `types.go` - HTTP request/response types + - [x] `RobotResponse` struct (with field mapping: `name` ← `member_id`, `description` ← `bio`) + - [x] `RobotStatusResponse` struct + - [x] `ListRobotsResponse` struct + - [x] `CreateRobotRequest` struct (HTTP binding) + - [x] `UpdateRobotRequest` struct (HTTP binding) + - [x] `NewRobotResponse()` - conversion from `api.RobotResponse` + - [x] `NewRobotStatusResponse()` - conversion from `api.RobotState` + +### 1.4 List Robots ✅ + +- [x] `list.go` - GET /v1/agent/robots +- [x] Parse query params: `status`, `keywords`, `page`, `pagesize`, `team_id` +- [x] Call `robot/api.ListRobots()` +- [x] Team constraint from auth info +- [x] Test: `tests/agent/robot_test.go#TestListRobots` + +### 1.5 Get Robot ✅ + +- [x] `detail.go` - GET /v1/agent/robots/:id +- [x] Parse path param +- [x] Call `robot/api.GetRobotResponse()` +- [x] Team access check +- [x] Test: `tests/agent/robot_test.go#TestGetRobot` + +### 1.6 Create Robot ✅ + +- [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) +- [x] Handle duplicate (409 Conflict) +- [x] Test: `tests/agent/robot_test.go#TestCreateRobot` + +### 1.7 Update Robot ✅ + +- [x] PUT /v1/agent/robots/:id handler +- [x] Parse HTTP request to `UpdateRobotRequest` +- [x] Team permission check +- [x] Apply `AuthScope` with UpdatedBy +- [x] Call `robot/api.UpdateRobot()` +- [x] Return updated robot +- [x] Test: `tests/agent/robot_test.go#TestUpdateRobot` + +### 1.8 Delete Robot ✅ + +- [x] DELETE /v1/agent/robots/:id handler +- [x] Team permission check +- [x] Call `robot/api.RemoveRobot()` +- [x] Handle running executions (409 Conflict) +- [x] Return success response +- [x] Test: `tests/agent/robot_test.go#TestDeleteRobot` + +### 1.9 Status Endpoint ✅ + +- [x] GET /v1/agent/robots/:id/status handler +- [x] Call `robot/api.GetRobotStatus()` +- [x] Return runtime status (running count, max, last/next run) +- [x] Test: `tests/agent/robot_test.go#TestGetRobotStatus` + +### 1.10 Utilities ✅ + +- [x] `utils.go` - helper functions + - [x] `GetLocale(c *gin.Context)` - extract locale from query/header + - [x] `ParseBoolValue()` - parse bool from string + +### 1.11 Permission Logic ✅ + +- [x] `permission.go` - permission check functions + - [x] `CanRead()` - read permission check (creator or team member) + - [x] `CanWrite()` - write permission check (creator only) + - [x] `GetEffectiveTeamID()` - get effective team_id (user_id for personal users) + - [x] `BuildListFilter()` - build list filter based on permissions +- [x] Apply permission checks in handlers: + - [x] `GetRobot` - check `CanRead()` with `YaoTeamID` and `YaoCreatedBy` + - [x] `GetRobotStatus` - check `CanRead()` + - [x] `UpdateRobot` - check `CanWrite()` + - [x] `DeleteRobot` - check `CanWrite()` + - [x] `ListRobots` - use `BuildListFilter()` for team filtering + - [x] `CreateRobot` - auto-set `__yao_team_id` to `user_id` for personal users +- [x] Add Yao permission fields to API layer: + - [x] `api/types.go` - add `YaoCreatedBy`, `YaoTeamID` to `RobotResponse` and `RobotState` + - [x] `api/robot.go` - populate permission fields in `recordToResponse()` and `GetRobotStatus()` + - [x] `store/robot.go` - add `__yao_*` fields to `robotFields` +- [x] Permission tests in `tests/agent/robot_test.go#TestRobotPermissions` + +--- + +## ✅ Phase 1-FE: Frontend Integration ✅ [Completed] + +**Goal:** Implement frontend SDK and integrate pages to validate Phase 1 deliverables +**Status:** ✅ Completed + +### 1-FE.1 SDK Implementation ✅ + +> Location: `cui/packages/cui/openapi/agent/robot/` + +- [x] Create `robot/types.ts` - TypeScript types for Robot API + - [x] `RobotFilter` - filter options for listing (including `autonomous_mode`) + - [x] `Robot` - robot data structure + - [x] `RobotStatusResponse` - runtime status + - [x] `RobotCreateRequest` / `RobotUpdateRequest` - CRUD requests + - [x] `RobotDeleteResponse` - delete response +- [x] Create `robot/robots.ts` - Robot API SDK class (`AgentRobots`) + - [x] `List(filter)` - GET /v1/agent/robots + - [x] `Get(id)` - GET /v1/agent/robots/:id + - [x] `GetStatus(id)` - GET /v1/agent/robots/:id/status + - [x] `Create(data)` - POST /v1/agent/robots + - [x] `Update(id, data)` - PUT /v1/agent/robots/:id + - [x] `Delete(id)` - DELETE /v1/agent/robots/:id +- [x] Create `robot/index.ts` - exports +- [x] Update `agent/api.ts` - add `robots` property to Agent class +- [x] Update `agent/index.ts` - export robot module +- [x] Linter check passed + +### 1-FE.2 Page Integration ✅ + +> Location: `cui/packages/cui/pages/mission-control/` + +- [x] Create `useRobots` hook for API calls + - [x] `listRobots(filter)` - list robots with pagination + - [x] `getRobot(id)` - get single robot + - [x] `getRobotStatus(id)` - get runtime status + - [x] `createRobot(data)` - create robot + - [x] `updateRobot(id, data)` - update robot + - [x] `deleteRobot(id)` - delete robot + - [x] Error handling and loading state +- [x] Robot List Page (`mission-control/index.tsx`) + - [x] Replace mock data with `listRobots()` API (fallback to mock) + - [x] Fetch status for each robot via `getRobotStatus()` + - [x] Refresh list after robot created/updated/deleted + - [x] Empty state with "Create Agent" button (with bubble animation) + - [ ] Implement pagination (TODO: Phase 2) + - [ ] Implement filters (status, keywords, team) (TODO: Phase 2) +- [x] Robot Detail Modal (`AgentModal`) + - [x] Real-time status refresh via `getRobotStatus(id)` + - [x] Auto-refresh every 10 seconds while modal open + - [x] Merge real-time status with robot data +- [x] Create Robot (`AddAgentModal`) + - [x] Call `createRobot()` API + - [x] Handle success/error messages + - [x] Form validation (existing) + - [x] Load email domains, managers, agents, MCP servers from API +- [x] Edit Robot (`ConfigTab` in `AgentModal`) + - [x] Load robot data from API (`getRobot()`) + - [x] Load email domains, managers, roles from Team API + - [x] Load agents and MCP servers from API + - [x] Pre-populate form with existing data + - [x] Call `updateRobot()` API with `robot_config.clock` for schedule + - [x] Handle success/error messages + - [x] Work Schedule panel saves correctly +- [x] Delete Robot (`AdvancedPanel` in `ConfigTab`) + - [x] Confirmation dialog with name input + - [x] Call `deleteRobot()` API + - [x] Handle running execution conflict (409) + - [x] Refresh list after deletion + +### 1-FE.3 UI/UX Enhancements ✅ + +- [x] `CreatureLoading` component with organic animations + - [x] Breathing aura, floating creature, orbit ring, particles + - [x] Three sizes: small, medium, large + - [x] Used in ConfigTab, ResultsTab, HistoryTab +- [x] Empty state "Create Agent" button with bubble animation + - [x] Cyan, purple, pink glowing bubbles rising +- [x] CSS variable compliance (`--color_mission_button_text`) +- [x] Consistent loading animations across all tabs + +### 1-FE.4 Verification ✅ + +- [x] Manual test: Create → List → Get → Update → Delete +- [ ] E2E automated test (TODO: Phase 3) +- [x] Permission test: Personal user vs Team user (manual tested) +- [x] Error handling: 400, 403, 404, 409, 500 + +--- + +## 🟢 Phase 2: Execution Management ⬜ [Low Risk] + +**Goal:** Execution listing, details, control, and trigger/intervene (single-submit mode) +**Risk:** 🟢 Low - Wraps existing API functions + +### 2.1 List Executions ⬜ + +- [ ] `execution.go` - GET /v1/robots/:id/executions +- [ ] Parse query params: `status`, `trigger_type`, `keyword`, `page`, `pagesize` +- [ ] Call `robot/api.GetExecutions()` +- [ ] Add derived fields: `name`, `current_task_name` +- [ ] Format response +- [ ] Test: `tests/robot/execution_list_test.go` + +### 2.2 Get Execution ⬜ + +- [ ] GET /v1/robots/:id/executions/:exec_id +- [ ] Call `robot/api.GetExecution()` +- [ ] Full task details with localization +- [ ] Test: `tests/robot/execution_get_test.go` + +### 2.3 Execution Control ⬜ + +- [ ] POST /v1/robots/:id/executions/:exec_id/pause + - [ ] Call `robot/api.Pause()` +- [ ] POST /v1/robots/:id/executions/:exec_id/resume + - [ ] Call `robot/api.Resume()` +- [ ] POST /v1/robots/:id/executions/:exec_id/cancel + - [ ] Call `robot/api.Stop()` +- [ ] POST /v1/robots/:id/executions/:exec_id/retry + - [ ] Re-trigger with same input +- [ ] Test: `tests/robot/execution_control_test.go` + +### 2.4 Execution Types ⬜ + +- [ ] Add to `types.go`: + - [ ] `ExecutionResponse` struct + - [ ] `TaskResponse` struct + - [ ] `CurrentStateResponse` struct + - [ ] `GoalsResponse` struct + - [ ] `DeliveryResultResponse` struct + +### 2.5 Trigger & Intervene (Single-Submit Mode) ⬜ + +> **Note:** This is single-submit mode. Multi-turn chat is deferred to Phase 5. + +- [ ] `trigger.go` - POST /v1/robots/:id/trigger +- [ ] Parse `TriggerRequest` (messages, attachments) +- [ ] Call `robot/api.Trigger()` +- [ ] Return execution ID and status +- [ ] Optional: Return SSE stream for progress +- [ ] Test: `tests/robot/trigger_test.go` + +- [ ] POST /v1/robots/:id/intervene +- [ ] Parse `InterveneRequest` +- [ ] Call `robot/api.Intervene()` +- [ ] Return result +- [ ] Test: `tests/robot/intervene_test.go` + +### 2.6 Trigger Types ⬜ + +- [ ] Add to `types.go`: + - [ ] `TriggerRequest` struct + - [ ] `TriggerResponse` struct + - [ ] `InterveneRequest` struct + - [ ] `InterveneResponse` struct + - [ ] `Message` struct + - [ ] `Attachment` struct + +### 2.7 Frontend Integration ⬜ + +> Integrate immediately after backend completion + +- [ ] SDK: Add execution methods to `robot.ts` + - [ ] `listExecutions(robotId, params)` + - [ ] `getExecution(robotId, execId)` + - [ ] `pauseExecution()`, `resumeExecution()`, `cancelExecution()` + - [ ] `triggerRobot(robotId, data)` + - [ ] `intervene(robotId, data)` +- [ ] Page: Execution list/detail page integration +- [ ] Page: Assign Task (trigger execution) integration +- [ ] Verify: E2E testing + +--- + +## 🟢 Phase 3: Results & Activities ⬜ [Low Risk] + +**Goal:** Deliverables listing and activity feed +**Risk:** 🟢 Low - Read-only queries, derived from existing data + +### 3.1 Backend Prerequisites ⬜ + +#### Store Layer (Core implementation) +- [ ] Add `ExecutionStore.ListResults()` - query deliverables from execution delivery data +- [ ] Add `ExecutionStore.GetResult()` - get single deliverable detail +- [ ] Add `ExecutionStore.ListActivities()` - derive activities from execution history + +#### API Layer (Thin wrappers) +- [ ] Create `api/results.go` with `ListResults()`, `GetResult()` - call store +- [ ] Create `api/activities.go` with `ListActivities()` - call store + +### 3.2 Results Endpoints ⬜ + +- [ ] `results.go` - results handlers +- [ ] GET /v1/robots/:id/results + - [ ] Parse filters: `trigger_type`, `keyword`, `page`, `pagesize` + - [ ] Call `robot/api.ListResults()` + - [ ] Format response +- [ ] GET /v1/robots/:id/results/:result_id + - [ ] Call `robot/api.GetResult()` + - [ ] Return full delivery content +- [ ] Test: `tests/robot/results_test.go` + +### 3.3 Results Types ⬜ + +- [ ] Add to `types.go`: + - [ ] `ResultResponse` struct + - [ ] `ResultDetailResponse` struct + - [ ] `DeliveryContentResponse` struct + - [ ] `DeliveryAttachmentResponse` struct + +### 3.4 Activities Endpoints ⬜ + +- [ ] `activities.go` - activities handlers +- [ ] GET /v1/robots/activities + - [ ] Parse: `limit`, `since` + - [ ] Call `robot/api.ListActivities()` + - [ ] Format response +- [ ] Test: `tests/robot/activities_test.go` + +### 3.5 Activity Types ⬜ + +- [ ] Add to `types.go`: + - [ ] `ActivityResponse` struct + - [ ] `ActivityType` constants + +### 3.6 Frontend Integration ⬜ + +> Integrate immediately after backend completion + +- [ ] SDK: Add results/activities methods to `robot.ts` + - [ ] `listResults(robotId, params)` + - [ ] `getResult(robotId, resultId)` + - [ ] `listActivities(params)` +- [ ] Page: Results Tab integration +- [ ] Page: Activity Feed integration +- [ ] Verify: E2E testing + +--- + +## 🟢 Phase 4: i18n ⬜ [Low Risk] + +**Goal:** Locale parameter support +**Risk:** 🟢 Low - Additive, optional parameter + +### 4.1 Locale Handling ⬜ + +- [ ] Add `getLocale(r *http.Request)` to utils.go +- [ ] Parse locale from query param, body, or header +- [ ] Add `Locale` field to context if needed + +### 4.2 Localized Responses ⬜ + +- [ ] Localize `display_name` in RobotResponse +- [ ] Localize `description` in RobotResponse +- [ ] Localize `name` in ExecutionResponse (derive from goals/input) +- [ ] Localize `current_task_name` in ExecutionResponse + +### 4.3 Frontend Integration ⬜ + +> Integrate immediately after backend completion + +- [ ] SDK: Add `locale` parameter support to all API calls +- [ ] Page: Use current language setting when calling APIs +- [ ] Verify: Data correctly localized after language switch + +--- + +## 🟡 Phase 5: Multi-turn Chat API ⬜ [Medium Risk - Deferred] + +> **Frontend Fallback:** Single-submit mode (user input → immediate execution) +> **Risk:** 🟡 Medium - New stateful component + +**Goal:** Multi-turn conversation before execution + +### 5.1 Backend Prerequisites ⬜ + +- [ ] Create `store/conversation.go` - temporary conversation storage (redis/memory) +- [ ] Create `types/conversation.go` - Conversation, ChatRequest, ChatResponse types +- [ ] Create `api/chat.go` - Chat() handler with LLM call +- [ ] Extend `api/trigger.go` - support `conversation_id` parameter + +### 5.2 Chat Endpoint ⬜ + +- [ ] POST /v1/robots/:id/chat (SSE) +- [ ] Parse ChatRequest (conversation_id, messages, attachments) +- [ ] Create or continue conversation +- [ ] Call LLM for response +- [ ] Store updated conversation +- [ ] Return assistant message + conversation_id +- [ ] Test: `tests/robot/chat_test.go` + +### 5.3 Trigger with Conversation ⬜ + +- [ ] Extend POST /v1/robots/:id/trigger +- [ ] Accept `conversation_id` parameter +- [ ] Use conversation history as execution input +- [ ] Auto-cleanup conversation after execution starts + +--- + +## 🟡 Phase 6: Real-time SSE Streams ⬜ [Medium Risk - Deferred] + +> **Frontend Fallback:** Polling (GET /executions every 3-5 seconds) +> **Risk:** 🟡 Medium - Requires modification of executor/manager + +**Goal:** SSE streams for real-time status updates + +### 6.1 Backend Event System ⬜ + +Need to add in `robot/`: + +- [ ] Create `events/bus.go` - Event bus for pub/sub +- [ ] Integrate event publishing in `manager/manager.go` +- [ ] Integrate event publishing in `executor/standard/executor.go` +- [ ] Publish: robot_status, execution_start, execution_complete, phase, task events + +### 6.2 Robot Status Stream ⬜ + +- [ ] `stream.go` - stream handlers +- [ ] GET /v1/robots/stream + - [ ] Subscribe to manager status updates + - [ ] Stream `robot_status` events + - [ ] Stream `execution_start` events + - [ ] Stream `execution_complete` events + - [ ] Stream `activity` events +- [ ] Test: `tests/robot/stream_test.go` + +### 6.3 Execution Progress Stream ⬜ + +- [ ] GET /v1/robots/:id/executions/:exec_id/stream + - [ ] Subscribe to execution updates + - [ ] Stream `phase` events + - [ ] Stream `task_start` / `task_complete` events + - [ ] Stream `message` events + - [ ] Stream `delivery` event + - [ ] Stream `complete` / `error` events +- [ ] Test: `tests/robot/execution_stream_test.go` + +--- + +## Backend Extensions Required + +> **Architecture:** Store layer handles CRUD, API layer handles business logic. +> This enables reuse across Golang API, JSAPI, and Yao Process. + +### robot/store/ Extensions (Core CRUD) + +| Function | Phase | Risk | Status | Description | +|----------|-------|------|--------|-------------| +| `RobotStore.Save()` | 1 | 🟢 Low | ✅ | Create/update robot member | +| `RobotStore.Get()` | 1 | 🟢 Low | ✅ | Get robot by member_id | +| `RobotStore.List()` | 1 | 🟢 Low | ✅ | List robots with filters | +| `RobotStore.Delete()` | 1 | 🟢 Low | ✅ | Delete robot member | +| `RobotStore.UpdateConfig()` | 1 | 🟢 Low | ✅ | Update config only | +| `RobotStore.UpdateStatus()` | 1 | 🟢 Low | ✅ | Update status only | +| `ExecutionStore.ListResults()` | 3 | 🟢 Low | ⬜ | Query deliverables from executions | +| `ExecutionStore.GetResult()` | 3 | 🟢 Low | ⬜ | Get single deliverable | +| `ExecutionStore.ListActivities()` | 3 | 🟢 Low | ⬜ | Derive activities from history | +| Conversation store | 5 | 🟡 Medium | ⬜ | Temporary chat history (Deferred) | + +### robot/types/ Extensions + +| Type/Field | Phase | Risk | Status | Description | +|------------|-------|------|--------|-------------| +| `Robot.Bio` | 1 | 🟢 Low | ✅ | Add field, maps to `__yao.member.bio` | +| Execution name derivation | 2 | 🟢 Low | ⬜ | Derive in OpenAPI layer from goals or input | + +> **Note:** `Robot.Name` is NOT needed. Frontend `name` maps to existing `Robot.MemberID`. + +### robot/cache/ Extensions + +| File | Phase | Risk | Status | Description | +|------|-------|------|--------|-------------| +| `load.go` | 1 | 🟢 Low | ✅ | Add `bio` to `memberFields` slice | + +### robot/utils/ Extensions + +| File | Phase | Risk | Status | Description | +|------|-------|------|--------|-------------| +| `convert.go` | 1 | 🟢 Low | ✅ | Unified type conversion utilities | +| `convert_test.go` | 1 | 🟢 Low | ✅ | Tests for conversion utilities | + +### robot/api/ Extensions (Thin wrappers calling store) + +| Function | Phase | Risk | Status | Description | +|----------|-------|------|--------|-------------| +| `CreateRobot()` | 1 | 🟢 Low | ✅ | Call `store.RobotStore.Save()` + cache refresh | +| `UpdateRobot()` | 1 | 🟢 Low | ✅ | Partial update + cache refresh | +| `RemoveRobot()` | 1 | 🟢 Low | ✅ | Call `store.RobotStore.Delete()` + cache invalidate | +| `GetRobotResponse()` | 1 | 🟢 Low | ✅ | Get robot as API response | +| `ListResults()` | 3 | 🟢 Low | ⬜ | Call `store.ExecutionStore.ListResults()` | +| `GetResult()` | 3 | 🟢 Low | ⬜ | Call `store.ExecutionStore.GetResult()` | +| `ListActivities()` | 3 | 🟢 Low | ⬜ | Call `store.ExecutionStore.ListActivities()` | +| `RetryExecution()` | 2 | 🟢 Low | ⬜ | Re-trigger with same input | +| `Chat()` | 5 | 🟡 Medium | ⬜ | Multi-turn conversation (Deferred) | + +### Event System (Phase 6 - Deferred) + +| Component | Phase | Risk | Description | +|-----------|-------|------|-------------| +| Event bus | 6 | 🟡 Medium | Pub/sub for real-time updates | +| Manager events | 6 | 🟡 Medium | Publish robot status changes | +| Executor events | 6 | 🟡 Medium | Publish execution progress | + +--- + +## Testing Strategy + +### Test Files Structure + +``` +yao/openapi/tests/robot/ +├── list_test.go +├── get_test.go +├── create_test.go +├── update_test.go +├── delete_test.go +├── execution_list_test.go +├── execution_get_test.go +├── execution_control_test.go +├── trigger_test.go +├── intervene_test.go +├── results_test.go +├── activities_test.go +├── stream_test.go +└── execution_stream_test.go +``` + +### Test Utilities + +- [ ] Create test robot helper +- [ ] Create test execution helper +- [ ] SSE client for streaming tests +- [ ] Mock data generators + +--- + +## Progress Tracking + +| Phase | Risk | Backend | Frontend | Description | +|-------|------|---------|----------|-------------| +| 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 | +| 5. Chat API | 🟡 | ⬜ | ⬜ | Multi-turn conversation (Deferred) | +| 6. SSE Streams | 🟡 | ⬜ | ⬜ | Real-time status updates (Deferred) | + +Legend: ⬜ Not started | 🟡 In progress | ✅ Complete + +### Phase 1 Detailed Status + +| Component | Status | Notes | +|-----------|--------|-------| +| `types.Robot.Bio` | ✅ | Field added | +| `cache/load.go` | ✅ | `bio` in memberFields | +| `store/robot.go` | ✅ | Full CRUD with permission fields | +| `store/robot_test.go` | ✅ | Integration tests | +| `api/robot.go` | ✅ | Create/Update/Remove/GetResponse | +| `api/types.go` | ✅ | Request/Response types, AuthScope | +| `api/robot_test.go` | ✅ | API tests | +| `utils/convert.go` | ✅ | Type conversion utilities | +| `utils/convert_test.go` | ✅ | Unit tests | +| `openapi/agent/robot/robot.go` | ✅ | Route registration with Attach() | +| `openapi/agent/robot/types.go` | ✅ | HTTP request/response types | +| `openapi/agent/robot/list.go` | ✅ | List robots handler with permission filter | +| `openapi/agent/robot/detail.go` | ✅ | CRUD handlers with permission checks | +| `openapi/agent/robot/permission.go` | ✅ | Permission check functions (CanRead/CanWrite) | +| `openapi/agent/robot/utils.go` | ✅ | Helper functions | +| `openapi/agent/agent.go` | ✅ | Robot routes registered | +| `openapi/tests/agent/robot_test.go` | ✅ | Integration tests + Permission tests | + +--- + +## Quick Reference + +### Current Location + +``` +yao/openapi/agent/robot/ # This directory (sub-package under agent) +├── DESIGN.md # Design document ✅ +├── TODO.md # This file ✅ +├── robot.go # Route registration (Attach function) ✅ +├── types.go # All request/response types ✅ +├── list.go # GET /v1/agent/robots ✅ +├── detail.go # GET/POST/PUT/DELETE /v1/agent/robots/:id ✅ +├── permission.go # Permission check functions (CanRead/CanWrite) ✅ +├── utils.go # Utilities ✅ +├── execution.go # Execution endpoints (Phase 2) +├── trigger.go # Trigger/Intervene SSE (Phase 2) +├── results.go # Results endpoints (Phase 3) +├── activities.go # Activities endpoint (Phase 3) +├── stream.go # Real-time streams (Phase 6 - Deferred) +└── filter.go # Query filtering (optional) +``` + +### Parent Directory + +``` +yao/openapi/agent/ +├── agent.go # MODIFY: add robot.Attach() call +├── assistant.go # Existing +├── filter.go # Existing +├── models.go # Existing +├── types.go # Existing +│ +└── robot/ # NEW sub-package (this directory) + └── ... +``` + +### Route Registration (in agent/agent.go) + +```go +import "github.com/yaoapp/yao/openapi/agent/robot" + +func Attach(group *gin.RouterGroup, oauth types.OAuth) { + group.Use(oauth.Guard) + + // Existing assistant routes + group.GET("/assistants", ListAssistants) + group.POST("/assistants", CreateAssistant) + group.GET("/assistants/tags", ListAssistantTags) + group.GET("/assistants/:id", GetAssistant) + group.GET("/assistants/:id/info", GetAssistantInfo) + group.PUT("/assistants/:id", UpdateAssistant) + + // Robot routes (NEW) + robot.Attach(group.Group("/robots"), oauth) +} +``` + +### Dependencies + +| Package | Usage | +|---------|-------| +| `yao/agent/robot/api` | Go API functions (Get, List, Trigger, etc.) | +| `yao/agent/robot/types` | Robot types (Robot, Execution, etc.) | +| `yao/openapi/oauth` | Authentication, Guard middleware | +| `yao/openapi/oauth/types` | OAuth types (AuthorizedInfo) | +| `yao/openapi/response` | Response helpers | + +### Import Path + +```go +package robot + +import ( + "github.com/gin-gonic/gin" + robotapi "github.com/yaoapp/yao/agent/robot/api" + robottypes "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/openapi/oauth/types" +) +``` + +--- + +## Notes + +### Priority + +| Priority | Phase | Required For | Risk | +|----------|-------|--------------|------| +| 1 | Phase 1 (CRUD) | Basic UI functionality | 🟢 Low | +| 2 | Phase 2 (Execution) | Active/History tabs, Assign Task | 🟢 Low | +| 3 | Phase 3 (Results) | Results tab | 🟢 Low | +| 4 | Phase 4 (i18n) | Multi-language support | 🟢 Low | +| 5 | Phase 5 (Chat) | Enhanced UX (deferred) | 🟡 Medium | +| 6 | Phase 6 (SSE) | Real-time updates (deferred) | 🟡 Medium | + +### Frontend Fallbacks + +| Feature | Full Implementation | Fallback | +|---------|---------------------|----------| +| Assign Task | Multi-turn chat → Confirm → Execute | Single-submit → Execute | +| Real-time Status | SSE push | Polling every 3-5s | + +### Frontend Integration + +**Execute immediately after each phase backend completion:** + +1. **SDK Implementation** - `cui/packages/cui/openapi/agent/robot/` +2. **Type Definitions** - TypeScript request/response types +3. **Hook Implementation** - `cui/packages/cui/hooks/useRobots.ts` +4. **Page Integration** - Replace mock data, call real APIs +5. **E2E Verification** - Full flow testing + +**File Locations:** +``` +cui/packages/cui/ +├── openapi/ +│ └── agent/ +│ └── robot/ +│ ├── types.ts # TypeScript types +│ ├── robots.ts # AgentRobots SDK class +│ └── index.ts # Exports +├── hooks/ +│ └── useRobots.ts # React hook for robot API calls +├── styles/ +│ └── preset/ +│ └── vars.less # CSS variables (--color_mission_button_text) +└── pages/ + └── mission-control/ + ├── index.tsx # Robot list (grid) page + ├── index.less # Styles with bubble animations + └── components/ + ├── AgentModal/ # Robot detail modal + ├── AddAgentModal/ # Create robot modal + └── CreatureLoading/ # Branded loading component + ├── index.tsx + └── index.less +``` + +### Incremental Deployment + +Each phase independently deliverable: + +| Phase | Backend | Frontend | Verifiable Features | +|-------|---------|----------|---------------------| +| 1 | ✅ | ✅ | Robot CRUD basic management | +| 2 | ⬜ | ⬜ | Execution list/control/trigger | +| 3 | ⬜ | ⬜ | Results/Activities viewing | +| 4 | ⬜ | ⬜ | Multi-language support | +| 5 | ⬜ | ⬜ | Multi-turn chat UX (optional) | +| 6 | ⬜ | ⬜ | Real-time push (optional) | diff --git a/openapi/agent/robot/detail.go b/openapi/agent/robot/detail.go new file mode 100644 index 00000000..9bb796d0 --- /dev/null +++ b/openapi/agent/robot/detail.go @@ -0,0 +1,432 @@ +package robot + +import ( + "strings" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/kun/log" + robotapi "github.com/yaoapp/yao/agent/robot/api" + robottypes "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/openapi/oauth/authorized" + "github.com/yaoapp/yao/openapi/response" +) + +// GetRobot retrieves a single robot by ID +// GET /v1/agent/robots/:id +func GetRobot(c *gin.Context) { + // Get authorized information + authInfo := authorized.GetInfo(c) + + // Get robot ID from URL parameter + robotID := c.Param("id") + if robotID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "robot id is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Create robot context + ctx := &robottypes.Context{} + + // Get robot via API + robotResp, err := robotapi.GetRobotResponse(ctx, robotID) + if err != nil { + log.Error("Failed to get robot %s: %v", robotID, err) + + // Check for not found error + if err == robottypes.ErrRobotNotFound { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Robot not found: " + robotID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to get robot: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Check read permission + // Permission rules: + // - No constraints: allow all + // - OwnerOnly: user must be the creator + // - TeamOnly: robot must belong to user's team + if !CanRead(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Forbidden: No permission to access this robot", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Convert to HTTP response + resp := NewResponse(robotResp) + response.RespondWithSuccess(c, response.StatusOK, resp) +} + +// GetRobotStatus retrieves the runtime status of a robot +// GET /v1/agent/robots/:id/status +func GetRobotStatus(c *gin.Context) { + // Get authorized information + authInfo := authorized.GetInfo(c) + + // Get robot ID from URL parameter + robotID := c.Param("id") + if robotID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "robot id is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Create robot context + ctx := &robottypes.Context{} + + // Get robot status via API + status, err := robotapi.GetRobotStatus(ctx, robotID) + if err != nil { + log.Error("Failed to get robot status %s: %v", robotID, err) + + if err == robottypes.ErrRobotNotFound { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Robot not found: " + robotID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to get robot status: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Check read permission + if !CanRead(c, authInfo, status.YaoTeamID, status.YaoCreatedBy) { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Forbidden: No permission to access this robot", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Convert to HTTP response + resp := NewStatusResponse(status) + response.RespondWithSuccess(c, response.StatusOK, resp) +} + +// CreateRobot creates a new robot +// POST /v1/agent/robots +func CreateRobot(c *gin.Context) { + // Get authorized information + authInfo := authorized.GetInfo(c) + + // Parse request body + var req CreateRobotRequest + if err := c.ShouldBindJSON(&req); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid request body: " + err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Validate required fields + if req.DisplayName == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "display_name is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + 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 + effectiveTeamID := GetEffectiveTeamID(authInfo) + if req.TeamID == "" { + req.TeamID = effectiveTeamID + } + + // Apply team constraint from auth if TeamOnly + if authInfo != nil && authInfo.Constraints.TeamOnly && authInfo.TeamID != "" { + // Force team_id to auth team_id + req.TeamID = authInfo.TeamID + } + + // Still require team_id after all fallbacks + if req.TeamID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "team_id is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Convert to API request + apiReq := req.ToAPICreateRequest() + + // Apply Yao permission fields + // Key rule: __yao_team_id = authInfo.TeamID if has team, otherwise = authInfo.UserID + if authInfo != nil { + yaoTeamID := authInfo.TeamID + if yaoTeamID == "" { + // For personal users (no team), use user_id as __yao_team_id + // This ensures the robot is scoped to the individual user + yaoTeamID = authInfo.UserID + } + apiReq.AuthScope = &robotapi.AuthScope{ + CreatedBy: authInfo.UserID, + TeamID: yaoTeamID, + TenantID: authInfo.TenantID, + } + } + + // Create robot context + ctx := &robottypes.Context{} + + // Call API layer + robotResp, err := robotapi.CreateRobot(ctx, apiReq) + if err != nil { + log.Error("Failed to create robot: %v", err) + + // Check for duplicate error + if strings.Contains(err.Error(), "already exists") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusConflict, errorResp) + return + } + + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to create robot: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Convert to HTTP response + resp := NewResponse(robotResp) + response.RespondWithSuccess(c, response.StatusCreated, resp) +} + +// UpdateRobot updates an existing robot +// PUT /v1/agent/robots/:id +func UpdateRobot(c *gin.Context) { + // Get authorized information + authInfo := authorized.GetInfo(c) + + // Get robot ID from URL parameter + robotID := c.Param("id") + if robotID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "robot id is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Parse request body + var req UpdateRobotRequest + if err := c.ShouldBindJSON(&req); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid request body: " + err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Create robot context + ctx := &robottypes.Context{} + + // Check permission - first get the robot to verify ownership/team + existingRobot, err := robotapi.GetRobotResponse(ctx, robotID) + if err != nil { + if err == robottypes.ErrRobotNotFound { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Robot not found: " + robotID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to get robot: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Check write permission (only creator can update) + if !CanWrite(c, authInfo, existingRobot.YaoTeamID, existingRobot.YaoCreatedBy) { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Forbidden: No permission to update this robot", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Convert to API request + apiReq := req.ToAPIUpdateRequest() + + // Apply Yao permission fields + if authInfo != nil { + apiReq.AuthScope = &robotapi.AuthScope{ + UpdatedBy: authInfo.UserID, + } + } + + // Call API layer + robotResp, err := robotapi.UpdateRobot(ctx, robotID, apiReq) + if err != nil { + log.Error("Failed to update robot %s: %v", robotID, err) + + if err == robottypes.ErrRobotNotFound { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Robot not found: " + robotID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to update robot: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Convert to HTTP response + resp := NewResponse(robotResp) + response.RespondWithSuccess(c, response.StatusOK, resp) +} + +// DeleteRobot deletes a robot +// DELETE /v1/agent/robots/:id +func DeleteRobot(c *gin.Context) { + // Get authorized information + authInfo := authorized.GetInfo(c) + + // Get robot ID from URL parameter + robotID := c.Param("id") + if robotID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "robot id is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Create robot context + ctx := &robottypes.Context{} + + // Check permission - first get the robot to verify ownership/team + existingRobot, err := robotapi.GetRobotResponse(ctx, robotID) + if err != nil { + if err == robottypes.ErrRobotNotFound { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Robot not found: " + robotID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to get robot: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Check write permission (only creator can delete) + if !CanWrite(c, authInfo, existingRobot.YaoTeamID, existingRobot.YaoCreatedBy) { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Forbidden: No permission to delete this robot", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Call API layer + err = robotapi.RemoveRobot(ctx, robotID) + if err != nil { + log.Error("Failed to delete robot %s: %v", robotID, err) + + // Check for running executions + if strings.Contains(err.Error(), "running executions") { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + } + response.RespondWithError(c, response.StatusConflict, errorResp) + return + } + + if err == robottypes.ErrRobotNotFound { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Robot not found: " + robotID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to delete robot: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Return success with no content + response.RespondWithSuccess(c, response.StatusOK, map[string]interface{}{ + "member_id": robotID, + "deleted": true, + }) +} diff --git a/openapi/agent/robot/list.go b/openapi/agent/robot/list.go new file mode 100644 index 00000000..1a7fc6f0 --- /dev/null +++ b/openapi/agent/robot/list.go @@ -0,0 +1,117 @@ +package robot + +import ( + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/kun/log" + robotapi "github.com/yaoapp/yao/agent/robot/api" + robottypes "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/openapi/oauth/authorized" + "github.com/yaoapp/yao/openapi/response" +) + +// ListRobots lists robots with pagination and filtering +// GET /v1/agent/robots +func ListRobots(c *gin.Context) { + // Get authorized information + authInfo := authorized.GetInfo(c) + + // Parse pagination parameters + page := 1 + if pageStr := c.Query("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + pageSize := 20 + if pageSizeStr := c.Query("pagesize"); pageSizeStr != "" { + if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 100 { + pageSize = ps + } + } + + // Parse filter parameters + requestedTeamID := strings.TrimSpace(c.Query("team_id")) + status := strings.TrimSpace(c.Query("status")) + keywords := strings.TrimSpace(c.Query("keywords")) + autonomousModeStr := strings.TrimSpace(c.Query("autonomous_mode")) + + // Apply permission-based filtering + // This ensures users only see robots they have access to: + // - No constraints: use requested team_id or no filter + // - TeamOnly: force filter to user's team + // - OwnerOnly: filter by user_id (personal resources) + effectiveTeamID := BuildListFilter(c, authInfo, requestedTeamID) + + // Build query + query := &robotapi.ListQuery{ + TeamID: effectiveTeamID, + Keywords: keywords, + Page: page, + PageSize: pageSize, + } + if status != "" { + query.Status = robottypes.RobotStatus(status) + } + // Parse autonomous_mode filter: "true" or "false" to filter, empty/other to show all + if autonomousModeStr == "true" { + autonomousMode := true + query.AutonomousMode = &autonomousMode + } else if autonomousModeStr == "false" { + autonomousMode := false + query.AutonomousMode = &autonomousMode + } + + // Create robot context + ctx := &robottypes.Context{} + + // Call API layer + result, err := robotapi.ListRobots(ctx, query) + if err != nil { + log.Error("Failed to list robots: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to list robots: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Convert to HTTP response format + robots := make([]*Response, 0, len(result.Data)) + for _, r := range result.Data { + robots = append(robots, newResponseFromRobot(r)) + } + + resp := &ListResponse{ + Data: robots, + Total: result.Total, + Page: result.Page, + PageSize: result.PageSize, + } + + response.RespondWithSuccess(c, response.StatusOK, resp) +} + +// newResponseFromRobot converts types.Robot to Response +func newResponseFromRobot(r *robottypes.Robot) *Response { + if r == nil { + return nil + } + + return &Response{ + Name: r.MemberID, // Frontend mapping: name ← member_id + Description: r.Bio, // Frontend mapping: description ← bio + MemberID: r.MemberID, + TeamID: r.TeamID, + RobotStatus: string(r.Status), + AutonomousMode: r.AutonomousMode, + DisplayName: r.DisplayName, + Bio: r.Bio, + SystemPrompt: r.SystemPrompt, + RobotEmail: r.RobotEmail, + } +} diff --git a/openapi/agent/robot/permission.go b/openapi/agent/robot/permission.go new file mode 100644 index 00000000..5b7055ea --- /dev/null +++ b/openapi/agent/robot/permission.go @@ -0,0 +1,135 @@ +package robot + +import ( + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/oauth/authorized" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// Permission check functions for robot access control +// +// Permission Rules: +// 1. No auth info or no constraints: allow all +// 2. OwnerOnly: user can only access resources they created (__yao_created_by == userID) +// 3. TeamOnly: user can access resources in their team (__yao_team_id == teamID) +// 4. For personal users (no team): __yao_team_id should be empty or equal to user_id +// +// Read vs Write: +// - Read: team members can read team resources +// - Write: only creator or team owner can write (update/delete) + +// CanRead checks if the user has read permission for a robot +// Read permission is granted if: +// - No auth info (public access) +// - No constraints (admin/system) +// - User is the creator (__yao_created_by == userID) +// - TeamOnly: robot belongs to user's team (__yao_team_id == teamID) +func CanRead(c *gin.Context, authInfo *types.AuthorizedInfo, robotTeamID, robotCreatedBy string) bool { + // No auth info, allow access (handled by OAuth guard) + if authInfo == nil { + return true + } + + // No constraints, allow access (admin/system user) + if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly { + return true + } + + // User is the creator - always allow + if robotCreatedBy != "" && robotCreatedBy == authInfo.UserID { + return true + } + + // TeamOnly constraint: check team membership + if authInfo.Constraints.TeamOnly && authorized.IsTeamMember(c) { + // Robot belongs to user's team + if robotTeamID != "" && robotTeamID == authInfo.TeamID { + return true + } + } + + // OwnerOnly constraint: only creator can access (already checked above) + // If we reach here with OwnerOnly, user is not the creator + if authInfo.Constraints.OwnerOnly { + return false + } + + return false +} + +// CanWrite checks if the user has write permission for a robot (update/delete) +// Write permission is more restrictive: +// - No auth info: deny (should not happen, OAuth guard will block) +// - No constraints: allow (admin/system) +// - User is the creator: allow +// - TeamOnly + OwnerOnly: user must be creator AND in the same team +func CanWrite(c *gin.Context, authInfo *types.AuthorizedInfo, robotTeamID, robotCreatedBy string) bool { + // No auth info, deny write access + if authInfo == nil { + return false + } + + // No constraints, allow access (admin/system user) + if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly { + return true + } + + // User is the creator - allow write + if robotCreatedBy != "" && robotCreatedBy == authInfo.UserID { + // If TeamOnly is also set, verify team membership + if authInfo.Constraints.TeamOnly { + if robotTeamID == "" || robotTeamID == authInfo.TeamID { + return true + } + return false + } + return true + } + + // Not the creator - deny write access + // (In the future, we could add team admin/owner check here) + return false +} + +// GetEffectiveTeamID returns the effective team_id for a robot +// For personal users (no team selected), returns user_id as team_id +// For team users, returns the selected team_id +func GetEffectiveTeamID(authInfo *types.AuthorizedInfo) string { + if authInfo == nil { + return "" + } + + // If user has a team selected, use it + if authInfo.TeamID != "" { + return authInfo.TeamID + } + + // For personal users, use user_id as team_id + // This ensures resources are scoped to the individual user + return authInfo.UserID +} + +// BuildListFilter builds filter conditions for listing robots based on permissions +// Returns teamID filter to apply to the query +func BuildListFilter(c *gin.Context, authInfo *types.AuthorizedInfo, requestedTeamID string) string { + if authInfo == nil { + return requestedTeamID + } + + // No constraints - use requested filter or no filter + if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly { + return requestedTeamID + } + + // TeamOnly constraint: force filter to user's team + if authInfo.Constraints.TeamOnly && authorized.IsTeamMember(c) { + return authInfo.TeamID + } + + // OwnerOnly constraint: filter by user_id as team_id (personal resources) + if authInfo.Constraints.OwnerOnly { + return authInfo.UserID + } + + return requestedTeamID +} diff --git a/openapi/agent/robot/robot.go b/openapi/agent/robot/robot.go new file mode 100644 index 00000000..a4cf498e --- /dev/null +++ b/openapi/agent/robot/robot.go @@ -0,0 +1,25 @@ +package robot + +import ( + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// Attach attaches the robot API handlers to the router with OAuth protection +// This provides OAuth-protected endpoints for robot management +// Base path: /v1/agent/robots +func Attach(group *gin.RouterGroup, oauth types.OAuth) { + + // Apply OAuth guard to all routes + group.Use(oauth.Guard) + + // Robot CRUD - Standard REST endpoints + group.GET("", ListRobots) // GET /robots - List robots with pagination and filtering + group.POST("", CreateRobot) // POST /robots - Create a new robot + group.GET("/:id", GetRobot) // GET /robots/:id - Get robot details + group.PUT("/:id", UpdateRobot) // PUT /robots/:id - Update robot + group.DELETE("/:id", DeleteRobot) // DELETE /robots/:id - Delete robot + + // Robot Status + group.GET("/:id/status", GetRobotStatus) // GET /robots/:id/status - Get robot runtime status +} diff --git a/openapi/agent/robot/types.go b/openapi/agent/robot/types.go new file mode 100644 index 00000000..7dd328de --- /dev/null +++ b/openapi/agent/robot/types.go @@ -0,0 +1,255 @@ +package robot + +import ( + "time" + + robotapi "github.com/yaoapp/yao/agent/robot/api" +) + +// ==================== Request Types ==================== + +// CreateRobotRequest - HTTP request for creating a robot +type CreateRobotRequest struct { + // 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 + Bio string `json:"bio,omitempty"` // Robot description + Avatar string `json:"avatar,omitempty"` // Avatar URL + + // Identity & Role + SystemPrompt string `json:"system_prompt,omitempty"` // System prompt + RoleID string `json:"role_id,omitempty"` // Role within team + ManagerID string `json:"manager_id,omitempty"` // Direct manager user_id + + // Status + Status string `json:"status,omitempty"` // Member status: active | inactive | pending | suspended + RobotStatus string `json:"robot_status,omitempty"` // Robot status: idle | working | paused | error | maintenance + AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Whether autonomous mode is enabled + + // Communication + RobotEmail string `json:"robot_email,omitempty"` // Robot email address + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array) + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array) + + // Capabilities + RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON + Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array) + MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array) + LanguageModel string `json:"language_model,omitempty"` // Language model name + + // Limits + CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD +} + +// UpdateRobotRequest - HTTP request for updating a robot +type UpdateRobotRequest struct { + // Profile + DisplayName *string `json:"display_name,omitempty"` // Display name + Bio *string `json:"bio,omitempty"` // Robot description + Avatar *string `json:"avatar,omitempty"` // Avatar URL + + // Identity & Role + SystemPrompt *string `json:"system_prompt,omitempty"` // System prompt + RoleID *string `json:"role_id,omitempty"` // Role within team + ManagerID *string `json:"manager_id,omitempty"` // Direct manager user_id + + // Status + Status *string `json:"status,omitempty"` // Member status + RobotStatus *string `json:"robot_status,omitempty"` // Robot status + AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Autonomous mode + + // Communication + RobotEmail *string `json:"robot_email,omitempty"` // Robot email address + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules + + // Capabilities + RobotConfig interface{} `json:"robot_config,omitempty"` // Robot config JSON + Agents interface{} `json:"agents,omitempty"` // Accessible agents + MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers + LanguageModel *string `json:"language_model,omitempty"` // Language model name + + // Limits + CostLimit *float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD +} + +// ==================== Response Types ==================== + +// Response - HTTP response for a robot +// Maps to frontend expectations: name ← member_id, description ← bio +type Response struct { + // Basic (mapped for frontend) + ID int64 `json:"id,omitempty"` + Name string `json:"name"` // Frontend name ← member_id + Description string `json:"description"` // Frontend description ← bio + + // Original fields + MemberID string `json:"member_id"` + TeamID string `json:"team_id"` + Status string `json:"status"` + RobotStatus string `json:"robot_status"` + AutonomousMode bool `json:"autonomous_mode"` + + // Profile + DisplayName string `json:"display_name"` + Bio string `json:"bio,omitempty"` + Avatar string `json:"avatar,omitempty"` + + // Identity & Role + SystemPrompt string `json:"system_prompt,omitempty"` + RoleID string `json:"role_id,omitempty"` + ManagerID string `json:"manager_id,omitempty"` + + // Communication + RobotEmail string `json:"robot_email,omitempty"` + AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` + EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` + + // Capabilities + RobotConfig interface{} `json:"robot_config,omitempty"` + Agents interface{} `json:"agents,omitempty"` + MCPServers interface{} `json:"mcp_servers,omitempty"` + LanguageModel string `json:"language_model,omitempty"` + + // Limits + CostLimit float64 `json:"cost_limit,omitempty"` + + // Ownership & Audit + InvitedBy string `json:"invited_by,omitempty"` + JoinedAt *time.Time `json:"joined_at,omitempty"` + + // Timestamps + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// StatusResponse - runtime status response +type StatusResponse struct { + MemberID string `json:"member_id"` + TeamID string `json:"team_id"` + DisplayName string `json:"display_name"` + Bio string `json:"bio,omitempty"` + Status string `json:"status"` // Robot runtime status + Running int `json:"running"` // Current running executions + MaxRunning int `json:"max_running"` // Maximum concurrent executions + LastRun *time.Time `json:"last_run,omitempty"` + NextRun *time.Time `json:"next_run,omitempty"` + RunningIDs []string `json:"running_ids,omitempty"` // IDs of running executions +} + +// ListResponse - paginated list response +type ListResponse struct { + Data []*Response `json:"data"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pagesize"` +} + +// ==================== Conversion Functions ==================== + +// NewResponse creates a Response from api.RobotResponse +func NewResponse(r *robotapi.RobotResponse) *Response { + if r == nil { + return nil + } + + return &Response{ + ID: r.ID, + Name: r.MemberID, // Frontend mapping: name ← member_id + Description: r.Bio, // Frontend mapping: description ← bio + MemberID: r.MemberID, + TeamID: r.TeamID, + Status: r.Status, + RobotStatus: r.RobotStatus, + AutonomousMode: r.AutonomousMode, + DisplayName: r.DisplayName, + Bio: r.Bio, + Avatar: r.Avatar, + SystemPrompt: r.SystemPrompt, + RoleID: r.RoleID, + ManagerID: r.ManagerID, + RobotEmail: r.RobotEmail, + AuthorizedSenders: r.AuthorizedSenders, + EmailFilterRules: r.EmailFilterRules, + RobotConfig: r.RobotConfig, + Agents: r.Agents, + MCPServers: r.MCPServers, + LanguageModel: r.LanguageModel, + CostLimit: r.CostLimit, + InvitedBy: r.InvitedBy, + JoinedAt: r.JoinedAt, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +// ToAPICreateRequest converts HTTP request to api.CreateRobotRequest +func (r *CreateRobotRequest) ToAPICreateRequest() *robotapi.CreateRobotRequest { + return &robotapi.CreateRobotRequest{ + MemberID: r.MemberID, + TeamID: r.TeamID, + DisplayName: r.DisplayName, + Bio: r.Bio, + Avatar: r.Avatar, + SystemPrompt: r.SystemPrompt, + RoleID: r.RoleID, + ManagerID: r.ManagerID, + Status: r.Status, + RobotStatus: r.RobotStatus, + AutonomousMode: r.AutonomousMode, + RobotEmail: r.RobotEmail, + AuthorizedSenders: r.AuthorizedSenders, + EmailFilterRules: r.EmailFilterRules, + RobotConfig: r.RobotConfig, + Agents: r.Agents, + MCPServers: r.MCPServers, + LanguageModel: r.LanguageModel, + CostLimit: r.CostLimit, + } +} + +// ToAPIUpdateRequest converts HTTP request to api.UpdateRobotRequest +func (r *UpdateRobotRequest) ToAPIUpdateRequest() *robotapi.UpdateRobotRequest { + return &robotapi.UpdateRobotRequest{ + DisplayName: r.DisplayName, + Bio: r.Bio, + Avatar: r.Avatar, + SystemPrompt: r.SystemPrompt, + RoleID: r.RoleID, + ManagerID: r.ManagerID, + Status: r.Status, + RobotStatus: r.RobotStatus, + AutonomousMode: r.AutonomousMode, + RobotEmail: r.RobotEmail, + AuthorizedSenders: r.AuthorizedSenders, + EmailFilterRules: r.EmailFilterRules, + RobotConfig: r.RobotConfig, + Agents: r.Agents, + MCPServers: r.MCPServers, + LanguageModel: r.LanguageModel, + CostLimit: r.CostLimit, + } +} + +// NewStatusResponse creates a StatusResponse from api.RobotState +func NewStatusResponse(s *robotapi.RobotState) *StatusResponse { + if s == nil { + return nil + } + + return &StatusResponse{ + MemberID: s.MemberID, + TeamID: s.TeamID, + DisplayName: s.DisplayName, + Bio: s.Bio, + Status: string(s.Status), + Running: s.Running, + MaxRunning: s.MaxRunning, + LastRun: s.LastRun, + NextRun: s.NextRun, + RunningIDs: s.RunningIDs, + } +} diff --git a/openapi/agent/robot/utils.go b/openapi/agent/robot/utils.go new file mode 100644 index 00000000..28c20175 --- /dev/null +++ b/openapi/agent/robot/utils.go @@ -0,0 +1,101 @@ +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 +// Priority: query param > Accept-Language header > default +func GetLocale(c *gin.Context) string { + // Check query param first + if locale := c.Query("locale"); locale != "" { + return strings.ToLower(strings.TrimSpace(locale)) + } + + // Check Accept-Language header + if acceptLang := c.GetHeader("Accept-Language"); acceptLang != "" { + // Parse first language from header (e.g., "en-US,en;q=0.9" -> "en-us") + parts := strings.Split(acceptLang, ",") + if len(parts) > 0 { + lang := strings.Split(parts[0], ";")[0] + return strings.ToLower(strings.TrimSpace(lang)) + } + } + + // Default locale + return "en-us" +} + +// ParseBoolValue parses various string formats into a boolean pointer +func ParseBoolValue(value string) *bool { + value = strings.ToLower(strings.TrimSpace(value)) + switch value { + case "1", "true", "yes", "on": + v := true + return &v + case "0", "false", "no", "off": + v := false + return &v + } + 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 +} diff --git a/openapi/agent/types.go b/openapi/agent/types.go index 137f7b14..038890da 100644 --- a/openapi/agent/types.go +++ b/openapi/agent/types.go @@ -49,7 +49,8 @@ type AssistantFilterParams struct { Page int PageSize int Keywords string - Type string + Type string // Single type filter + Types []string // Multiple types filter (IN query) Connector string AssistantID string AssistantIDs []string @@ -70,6 +71,7 @@ func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilt Keywords: params.Keywords, Tags: params.Tags, Type: params.Type, + Types: params.Types, Connector: params.Connector, AssistantID: params.AssistantID, AssistantIDs: params.AssistantIDs, @@ -79,8 +81,8 @@ func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilt Automated: params.Automated, } - // Set default type if not specified - if filter.Type == "" { + // Set default type if not specified (only when Types is also empty) + if filter.Type == "" && len(filter.Types) == 0 { filter.Type = "assistant" } diff --git a/openapi/tests/agent/robot_test.go b/openapi/tests/agent/robot_test.go new file mode 100644 index 00000000..559c7252 --- /dev/null +++ b/openapi/tests/agent/robot_test.go @@ -0,0 +1,944 @@ +package openapi_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +// TestListRobots tests the robot listing endpoint +func TestListRobots(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client and get token + client := testutils.RegisterTestClient(t, "Robot List 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("ListRobotsSuccess", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.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) + require.NoError(t, err) + + // Verify pagination fields exist + assert.Contains(t, response, "data") + assert.Contains(t, response, "page") + assert.Contains(t, response, "pagesize") + assert.Contains(t, response, "total") + }) + + t.Run("ListRobotsWithPagination", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots?page=1&pagesize=5", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + require.NoError(t, err) + + assert.Equal(t, float64(1), response["page"]) + assert.Equal(t, float64(5), response["pagesize"]) + }) + + t.Run("ListRobotsWithAutonomousModeFilter", func(t *testing.T) { + // Test with autonomous_mode=true + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots?autonomous_mode=true", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + require.NoError(t, err) + + // Verify response structure + assert.Contains(t, response, "data") + assert.Contains(t, response, "total") + + // If there are robots, verify they are all autonomous + if data, ok := response["data"].([]interface{}); ok && len(data) > 0 { + for _, item := range data { + if robot, ok := item.(map[string]interface{}); ok { + assert.True(t, robot["autonomous_mode"].(bool), "All robots should have autonomous_mode=true") + } + } + } + }) + + t.Run("ListRobotsWithAutonomousModeFalse", func(t *testing.T) { + // Test with autonomous_mode=false + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots?autonomous_mode=false", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + require.NoError(t, err) + + // Verify response structure + assert.Contains(t, response, "data") + assert.Contains(t, response, "total") + + // If there are robots, verify they are all on-demand (not autonomous) + if data, ok := response["data"].([]interface{}); ok && len(data) > 0 { + for _, item := range data { + if robot, ok := item.(map[string]interface{}); ok { + assert.False(t, robot["autonomous_mode"].(bool), "All robots should have autonomous_mode=false") + } + } + } + }) + + t.Run("ListRobotsUnauthorized", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots", nil) + require.NoError(t, err) + + // No Authorization header + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +// TestCreateRobot tests the robot creation endpoint +func TestCreateRobot(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client and get token + client := testutils.RegisterTestClient(t, "Robot Create 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") + + // Track created robots for cleanup + var createdRobotIDs []string + defer func() { + // Cleanup created robots + for _, robotID := range createdRobotIDs { + req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + http.DefaultClient.Do(req) + } + }() + + t.Run("CreateRobotSuccess", func(t *testing.T) { + robotID := fmt.Sprintf("test_robot_%d", time.Now().UnixNano()) + + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": "test_team_001", + "display_name": "Test Robot", + "bio": "A test robot for API testing", + "robot_email": "test@robot.local", + } + + body, _ := json.Marshal(createData) + req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + require.NoError(t, err) + + assert.Equal(t, robotID, response["member_id"]) + assert.Equal(t, "Test Robot", response["display_name"]) + assert.Equal(t, "A test robot for API testing", response["bio"]) + + // Track for cleanup + createdRobotIDs = append(createdRobotIDs, robotID) + }) + + t.Run("CreateRobotMissingRequiredFields", func(t *testing.T) { + // Missing member_id + createData := map[string]interface{}{ + "team_id": "test_team_001", + "display_name": "Test Robot", + } + + body, _ := json.Marshal(createData) + req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("CreateRobotDuplicate", func(t *testing.T) { + robotID := fmt.Sprintf("test_robot_dup_%d", time.Now().UnixNano()) + + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": "test_team_001", + "display_name": "Test Robot Duplicate", + } + + body, _ := json.Marshal(createData) + + // First create + req1, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + req1.Header.Set("Content-Type", "application/json") + req1.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + resp1, err := http.DefaultClient.Do(req1) + require.NoError(t, err) + resp1.Body.Close() + assert.Equal(t, http.StatusCreated, resp1.StatusCode) + createdRobotIDs = append(createdRobotIDs, robotID) + + // Second create with same ID should fail + body2, _ := json.Marshal(createData) + req2, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body2)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + resp2, err := http.DefaultClient.Do(req2) + require.NoError(t, err) + defer resp2.Body.Close() + + assert.Equal(t, http.StatusConflict, resp2.StatusCode) + }) +} + +// TestGetRobot tests the robot get endpoint +func TestGetRobot(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client and get token + client := testutils.RegisterTestClient(t, "Robot Get 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") + + // Create a test robot first + robotID := fmt.Sprintf("test_robot_get_%d", time.Now().UnixNano()) + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": "test_team_001", + "display_name": "Test Robot Get", + "bio": "A robot for get test", + } + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + createResp.Body.Close() + + // Cleanup + defer func() { + req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + http.DefaultClient.Do(req) + }() + + t.Run("GetRobotSuccess", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.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) + require.NoError(t, err) + + assert.Equal(t, robotID, response["member_id"]) + assert.Equal(t, "Test Robot Get", response["display_name"]) + }) + + t.Run("GetRobotNotFound", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +// TestUpdateRobot tests the robot update endpoint +func TestUpdateRobot(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client and get token + client := testutils.RegisterTestClient(t, "Robot Update 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") + + // Create a test robot first + robotID := fmt.Sprintf("test_robot_update_%d", time.Now().UnixNano()) + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": "test_team_001", + "display_name": "Test Robot Update", + "bio": "Original bio", + } + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + createResp.Body.Close() + + // Cleanup + defer func() { + req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + http.DefaultClient.Do(req) + }() + + t.Run("UpdateRobotSuccess", func(t *testing.T) { + updateData := map[string]interface{}{ + "display_name": "Updated Robot Name", + "bio": "Updated bio", + } + + body, _ := json.Marshal(updateData) + req, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/"+robotID, bytes.NewBuffer(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.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) + require.NoError(t, err) + + assert.Equal(t, "Updated Robot Name", response["display_name"]) + assert.Equal(t, "Updated bio", response["bio"]) + }) + + t.Run("UpdateRobotNotFound", func(t *testing.T) { + updateData := map[string]interface{}{ + "display_name": "Updated Name", + } + + body, _ := json.Marshal(updateData) + req, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/non_existent_robot", bytes.NewBuffer(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +// TestDeleteRobot tests the robot delete endpoint +func TestDeleteRobot(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client and get token + client := testutils.RegisterTestClient(t, "Robot Delete 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("DeleteRobotSuccess", func(t *testing.T) { + // Create a test robot first + robotID := fmt.Sprintf("test_robot_delete_%d", time.Now().UnixNano()) + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": "test_team_001", + "display_name": "Test Robot Delete", + } + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + createResp.Body.Close() + + // Delete the robot + req, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.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) + require.NoError(t, err) + + assert.Equal(t, true, response["deleted"]) + assert.Equal(t, robotID, response["member_id"]) + + // Verify it's deleted by trying to get it + getReq, _ := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil) + getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + getResp, err := http.DefaultClient.Do(getReq) + require.NoError(t, err) + defer getResp.Body.Close() + + assert.Equal(t, http.StatusNotFound, getResp.StatusCode) + }) + + t.Run("DeleteRobotNotFound", func(t *testing.T) { + req, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/non_existent_robot", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +// TestGetRobotStatus tests the robot status endpoint +func TestGetRobotStatus(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client and get token + client := testutils.RegisterTestClient(t, "Robot Status 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") + + // Create a test robot first + robotID := fmt.Sprintf("test_robot_status_%d", time.Now().UnixNano()) + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": "test_team_001", + "display_name": "Test Robot Status", + "autonomous_mode": true, + } + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + createResp.Body.Close() + + // Cleanup + defer func() { + req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil) + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + http.DefaultClient.Do(req) + }() + + t.Run("GetRobotStatusSuccess", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/status", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.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) + require.NoError(t, err) + + assert.Equal(t, robotID, response["member_id"]) + assert.Contains(t, response, "status") + assert.Contains(t, response, "running") + assert.Contains(t, response, "max_running") + }) + + t.Run("GetRobotStatusNotFound", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot/status", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +// TestRobotPermissions tests robot permission scenarios +// Tests personal user vs team user access control +func TestRobotPermissions(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + // Get base URL from server config + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client + client := testutils.RegisterTestClient(t, "Robot Permission Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, client.ClientID) + + // Create User 1 (Personal user - no team) + token1 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + user1ID := token1.UserID + + // Create User 2 (Different user) + token2 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + user2ID := token2.UserID + + t.Logf("Test users created: User1=%s, User2=%s", user1ID, user2ID) + + // Track created robots for cleanup + var createdRobotIDs []string + defer func() { + for _, robotID := range createdRobotIDs { + req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil) + req.Header.Set("Authorization", "Bearer "+token1.AccessToken) + http.DefaultClient.Do(req) + } + }() + + t.Run("PersonalUserCreateRobot", func(t *testing.T) { + // Personal user creates a robot with their user_id as team_id + // This simulates a personal user (no team) creating their own robot + robotID := fmt.Sprintf("test_personal_robot_%d", time.Now().UnixNano()) + + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": user1ID, // Personal user: team_id = user_id + "display_name": "Personal Robot", + "bio": "A robot created by a personal user", + } + + body, _ := json.Marshal(createData) + req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + require.NoError(t, err) + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token1.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var response map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&response) + require.NoError(t, err) + + assert.Equal(t, robotID, response["member_id"]) + assert.Equal(t, user1ID, response["team_id"]) + t.Logf("Personal robot created: %s (team_id: %s)", robotID, user1ID) + createdRobotIDs = append(createdRobotIDs, robotID) + }) + + t.Run("PersonalUserCanAccessOwnRobot", func(t *testing.T) { + // User 1 creates a robot + robotID := fmt.Sprintf("test_own_robot_%d", time.Now().UnixNano()) + + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": user1ID, // Personal user: team_id = user_id + "display_name": "User 1 Robot", + } + + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + createResp, _ := http.DefaultClient.Do(createReq) + createResp.Body.Close() + createdRobotIDs = append(createdRobotIDs, robotID) + + // User 1 can access their own robot + getReq, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil) + require.NoError(t, err) + getReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + + getResp, err := http.DefaultClient.Do(getReq) + require.NoError(t, err) + defer getResp.Body.Close() + + assert.Equal(t, http.StatusOK, getResp.StatusCode) + t.Logf("User 1 successfully accessed their own robot: %s", robotID) + }) + + t.Run("PersonalUserCanUpdateOwnRobot", func(t *testing.T) { + // User 1 creates a robot + robotID := fmt.Sprintf("test_update_robot_%d", time.Now().UnixNano()) + + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": user1ID, + "display_name": "Original Name", + } + + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + createResp, _ := http.DefaultClient.Do(createReq) + createResp.Body.Close() + createdRobotIDs = append(createdRobotIDs, robotID) + + // User 1 can update their own robot + updateData := map[string]interface{}{ + "display_name": "Updated Name", + } + + updateBody, _ := json.Marshal(updateData) + updateReq, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/"+robotID, bytes.NewBuffer(updateBody)) + require.NoError(t, err) + updateReq.Header.Set("Content-Type", "application/json") + updateReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + + updateResp, err := http.DefaultClient.Do(updateReq) + require.NoError(t, err) + defer updateResp.Body.Close() + + assert.Equal(t, http.StatusOK, updateResp.StatusCode) + + var response map[string]interface{} + json.NewDecoder(updateResp.Body).Decode(&response) + assert.Equal(t, "Updated Name", response["display_name"]) + t.Logf("User 1 successfully updated their own robot") + }) + + t.Run("PersonalUserCanDeleteOwnRobot", func(t *testing.T) { + // User 1 creates a robot + robotID := fmt.Sprintf("test_delete_robot_%d", time.Now().UnixNano()) + + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": user1ID, + "display_name": "Robot to Delete", + } + + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + createResp, _ := http.DefaultClient.Do(createReq) + createResp.Body.Close() + + // User 1 can delete their own robot + deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil) + require.NoError(t, err) + deleteReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + + deleteResp, err := http.DefaultClient.Do(deleteReq) + require.NoError(t, err) + defer deleteResp.Body.Close() + + assert.Equal(t, http.StatusOK, deleteResp.StatusCode) + t.Logf("User 1 successfully deleted their own robot") + }) + + t.Run("TeamRobotAccess", func(t *testing.T) { + // Create a robot with a shared team_id + sharedTeamID := fmt.Sprintf("team_%d", time.Now().UnixNano()) + robotID := fmt.Sprintf("test_team_robot_%d", time.Now().UnixNano()) + + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": sharedTeamID, + "display_name": "Team Robot", + } + + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + createResp, _ := http.DefaultClient.Do(createReq) + createResp.Body.Close() + createdRobotIDs = append(createdRobotIDs, robotID) + + // Creator can access the team robot + getReq, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID, nil) + require.NoError(t, err) + getReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + + getResp, err := http.DefaultClient.Do(getReq) + require.NoError(t, err) + defer getResp.Body.Close() + + assert.Equal(t, http.StatusOK, getResp.StatusCode) + t.Logf("Creator successfully accessed team robot: %s (team: %s)", robotID, sharedTeamID) + }) + + t.Run("VerifyYaoPermissionFieldsSet", func(t *testing.T) { + // Create a robot and verify __yao_created_by and __yao_team_id are set + robotID := fmt.Sprintf("test_perm_fields_%d", time.Now().UnixNano()) + + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": user1ID, // Personal user: team_id = user_id + "display_name": "Permission Fields Test Robot", + } + + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + createdRobotIDs = append(createdRobotIDs, robotID) + + assert.Equal(t, http.StatusCreated, createResp.StatusCode) + + var response map[string]interface{} + json.NewDecoder(createResp.Body).Decode(&response) + + // The response should contain the robot data + // Note: __yao_created_by and __yao_team_id might not be in the public response + // but they should be set in the database + assert.Equal(t, robotID, response["member_id"]) + t.Logf("Robot created with permission fields (user_id: %s)", user1ID) + }) + + t.Run("DifferentUserCannotUpdateRobot", func(t *testing.T) { + // User 1 creates a robot + robotID := fmt.Sprintf("test_cross_update_%d", time.Now().UnixNano()) + + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": user1ID, + "display_name": "User 1 Private Robot", + } + + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + createResp, _ := http.DefaultClient.Do(createReq) + createResp.Body.Close() + createdRobotIDs = append(createdRobotIDs, robotID) + + // User 2 attempts to update User 1's robot - should be denied + // Note: With system:root scope, this might still succeed due to admin privileges + // In production, user2 would not have system:root + updateData := map[string]interface{}{ + "display_name": "Unauthorized Update", + } + + updateBody, _ := json.Marshal(updateData) + updateReq, err := http.NewRequest("PUT", serverURL+baseURL+"/agent/robots/"+robotID, bytes.NewBuffer(updateBody)) + require.NoError(t, err) + updateReq.Header.Set("Content-Type", "application/json") + updateReq.Header.Set("Authorization", "Bearer "+token2.AccessToken) + + updateResp, err := http.DefaultClient.Do(updateReq) + require.NoError(t, err) + defer updateResp.Body.Close() + + // With system:root scope (no constraints), user2 can still update + // This test documents the current behavior with admin privileges + t.Logf("User 2 update attempt status: %d (with system:root scope)", updateResp.StatusCode) + }) + + t.Run("DifferentUserCannotDeleteRobot", func(t *testing.T) { + // User 1 creates a robot + robotID := fmt.Sprintf("test_cross_delete_%d", time.Now().UnixNano()) + + createData := map[string]interface{}{ + "member_id": robotID, + "team_id": user1ID, + "display_name": "User 1 Robot for Delete Test", + } + + body, _ := json.Marshal(createData) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + createResp, _ := http.DefaultClient.Do(createReq) + createResp.Body.Close() + createdRobotIDs = append(createdRobotIDs, robotID) + + // User 2 attempts to delete User 1's robot + // Note: With system:root scope, this might still succeed due to admin privileges + deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil) + require.NoError(t, err) + deleteReq.Header.Set("Authorization", "Bearer "+token2.AccessToken) + + deleteResp, err := http.DefaultClient.Do(deleteReq) + require.NoError(t, err) + defer deleteResp.Body.Close() + + // With system:root scope (no constraints), user2 can still delete + // This test documents the current behavior with admin privileges + t.Logf("User 2 delete attempt status: %d (with system:root scope)", deleteResp.StatusCode) + }) + + t.Run("ListRobotsWithTeamFilter", func(t *testing.T) { + // Create robots for both users + robot1ID := fmt.Sprintf("test_list_user1_%d", time.Now().UnixNano()) + robot2ID := fmt.Sprintf("test_list_user2_%d", time.Now().UnixNano()) + + // User 1 creates their robot + create1 := map[string]interface{}{ + "member_id": robot1ID, + "team_id": user1ID, + "display_name": "User 1 List Robot", + } + body1, _ := json.Marshal(create1) + req1, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body1)) + req1.Header.Set("Content-Type", "application/json") + req1.Header.Set("Authorization", "Bearer "+token1.AccessToken) + resp1, _ := http.DefaultClient.Do(req1) + resp1.Body.Close() + createdRobotIDs = append(createdRobotIDs, robot1ID) + + // User 2 creates their robot + create2 := map[string]interface{}{ + "member_id": robot2ID, + "team_id": user2ID, + "display_name": "User 2 List Robot", + } + body2, _ := json.Marshal(create2) + req2, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body2)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("Authorization", "Bearer "+token2.AccessToken) + resp2, _ := http.DefaultClient.Do(req2) + resp2.Body.Close() + createdRobotIDs = append(createdRobotIDs, robot2ID) + + // User 1 lists robots with their team_id filter + listReq, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots?team_id="+user1ID, nil) + require.NoError(t, err) + listReq.Header.Set("Authorization", "Bearer "+token1.AccessToken) + + listResp, err := http.DefaultClient.Do(listReq) + require.NoError(t, err) + defer listResp.Body.Close() + + assert.Equal(t, http.StatusOK, listResp.StatusCode) + + var response map[string]interface{} + json.NewDecoder(listResp.Body).Decode(&response) + + data := response["data"].([]interface{}) + t.Logf("User 1 sees %d robots with team_id=%s filter", len(data), user1ID) + }) +} diff --git a/yao/assistants/robot_prompt/package.yao b/yao/assistants/robot_prompt/package.yao new file mode 100644 index 00000000..449c2ed4 --- /dev/null +++ b/yao/assistants/robot_prompt/package.yao @@ -0,0 +1,9 @@ +{ + "name": "Robot Prompt Generator", + "description": "Generate system prompts for autonomous robots", + "type": "worker", + "uses": { "search": "disabled" }, + "options": { + "temperature": 0.7 + } +} diff --git a/yao/assistants/robot_prompt/prompts.yml b/yao/assistants/robot_prompt/prompts.yml new file mode 100644 index 00000000..0e4240b0 --- /dev/null +++ b/yao/assistants/robot_prompt/prompts.yml @@ -0,0 +1,91 @@ +- role: system + content: | + You are an expert at crafting system prompts for autonomous AI robots (agents). + + Task: + Given a brief role description, generate a comprehensive system prompt that defines: + 1. Identity: Who the robot is + 2. Responsibilities: What the robot should do + 3. Constraints: Rules and limitations + 4. Style: Communication tone and approach + + Output: + - Return ONLY the system prompt text + - NO markdown code blocks, NO quotes, NO explanation + - Just the prompt content itself + - Use the SAME LANGUAGE as the user's input + + Structure (adapt based on role): + ``` + You are [role description]. + + ## Core Responsibilities + - [duty 1] + - [duty 2] + - [duty 3] + + ## Working Principles + - [principle 1] + - [principle 2] + + ## Constraints + - [constraint 1] + - [constraint 2] + + ## Communication Style + - [style guideline] + ``` + + Examples: + + Input: "Sales Analyst" + Output: + You are a Sales Analyst responsible for analyzing sales data and providing actionable insights. + + ## Core Responsibilities + - Analyze daily/weekly/monthly sales trends + - Identify top-performing products and regions + - Generate sales forecast reports + - Alert on significant anomalies or opportunities + + ## Working Principles + - Always base conclusions on data, not assumptions + - Prioritize actionable insights over raw statistics + - Consider seasonal factors and market context + + ## Constraints + - Only access authorized sales databases + - Do not make pricing or strategy decisions + - Escalate sensitive findings to management + + ## Communication Style + - Clear, concise, business-focused language + - Use charts and tables when presenting data + - Lead with key findings, details follow + + --- + + Input: "你是工程师" + Output: + 你是一名专注于技术问题解决的工程师助手。 + + ## 核心职责 + - 分析和诊断技术问题 + - 提供解决方案和最佳实践建议 + - 编写和审查代码 + - 监控系统健康状态 + + ## 工作原则 + - 先理解问题根因,再提供解决方案 + - 优先考虑稳定性和可维护性 + - 遵循团队编码规范和架构标准 + + ## 约束条件 + - 仅在授权范围内操作系统 + - 重大变更需人工确认 + - 不自行决定架构重构 + + ## 沟通风格 + - 技术准确,表达简洁 + - 提供代码示例时注明语言和版本 + - 复杂概念配合示意图说明