From 2d86c9caad10827e11d0ada98166691722b920dd Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 16 Jan 2026 16:29:39 +0800 Subject: [PATCH] Enhance Goals and Delivery Structure in Robot Agent - Introduced a structured Goals type with delivery metadata for improved task management and output handling. - Updated the DeliveryTarget type to include additional fields for recipients, format, and options, enhancing flexibility in result delivery. - Enhanced the Executor's RunGoals method to parse and handle delivery information from agent responses. - Revised input formatting to include robot identity context, improving clarity in generated goals. - Updated tests to validate the new structure and ensure comprehensive coverage of delivery functionalities. --- agent/robot/DESIGN.md | 51 +- agent/robot/TECHNICAL.md | 52 ++- agent/robot/executor/standard/goals.go | 158 ++++++- agent/robot/executor/standard/goals_test.go | 486 ++++++++++++++++++++ agent/robot/executor/standard/input.go | 44 +- agent/robot/executor/standard/input_test.go | 2 +- agent/robot/types/robot.go | 8 +- agent/robot/types/robot_test.go | 22 +- 8 files changed, 767 insertions(+), 56 deletions(-) create mode 100644 agent/robot/executor/standard/goals_test.go diff --git a/agent/robot/DESIGN.md b/agent/robot/DESIGN.md index 16430c0e..228b71c9 100644 --- a/agent/robot/DESIGN.md +++ b/agent/robot/DESIGN.md @@ -309,8 +309,24 @@ type ClockContext struct { **For Human/Event:** Uses the input directly as goals (or to generate goals). +```go +type Goals struct { + Content string // markdown text (for LLM) + Delivery *DeliveryTarget // where to send results (for P4) +} + +type DeliveryTarget struct { + Type DeliveryType // email | webhook | report | notification + Recipients []string // email addresses, webhook URLs, user IDs + Format string // markdown | html | json | text + Template string // template name + Options map[string]interface{} +} +``` + +**Example prompt:** + ``` -Prompt: You are [Sales Manager]. Your job: [track KPIs, make reports]. ## Report @@ -326,20 +342,26 @@ You are [Sales Manager]. Your job: [track KPIs, make reports]. Make today's goals. ``` +**Note:** Validation criteria (`ExpectedOutput`, `ValidationRules`) are defined at the **Task level** (P2), not Goals level. This allows each task to have specific validation rules for P3. + ### 4.4 P2: Tasks P2 Agent reads Goals markdown and breaks into executable tasks: ```go type Task struct { - ID string // unique task ID - Messages []context.Message // original input (text, images, files, audio) - GoalRef string // reference to goal (e.g., "Goal 1") - Source TaskSource // auto | human | event - ExecutorType ExecutorType // assistant | mcp | process - ExecutorID string // agent ID or mcp tool name - Args []any // arguments for executor - Order int // execution order + ID string // unique task ID + Messages []context.Message // original input (text, images, files, audio) + GoalRef string // reference to goal (e.g., "Goal 1") + Source TaskSource // auto | human | event + ExecutorType ExecutorType // assistant | mcp | process + ExecutorID string // agent ID or mcp tool name + Args []any // arguments for executor + Order int // execution order + + // Validation criteria (used in P3) + ExpectedOutput string // what the task should produce + ValidationRules []string // specific checks to perform } ``` @@ -349,9 +371,18 @@ For each task: 1. Call Assistant or MCP Tool 2. Get result -3. Validate +3. Validate against `ExpectedOutput` and `ValidationRules` 4. Update status +```go +type ValidationResult struct { + Passed bool // overall validation passed + Score float64 // 0-1 confidence score + Issues []string // what failed + Suggestions []string // how to improve +} +``` + ### 4.6 P4: Deliver Send output: diff --git a/agent/robot/TECHNICAL.md b/agent/robot/TECHNICAL.md index 21ba54f3..256ee4a7 100644 --- a/agent/robot/TECHNICAL.md +++ b/agent/robot/TECHNICAL.md @@ -1287,7 +1287,7 @@ type CurrentState struct { Progress string `json:"progress,omitempty"` // human-readable progress (e.g., "2/5 tasks") } -// Goals - P1 output (markdown for LLM) +// Goals - P1 output (markdown for LLM + structured metadata) // P1 Agent reads InspirationReport and generates goals as markdown // Example: // ## Goals @@ -1298,7 +1298,17 @@ type CurrentState struct { // 3. [Low] Update CRM with new leads // - Reason: 3 pending leads from yesterday type Goals struct { - Content string `json:"content"` // markdown text + Content string `json:"content"` // markdown text + Delivery *DeliveryTarget `json:"delivery,omitempty"` // where to send results (for P4) +} + +// DeliveryTarget - where to deliver results (defined in P1, used in P4) +type DeliveryTarget struct { + Type DeliveryType `json:"type"` // email | webhook | report | notification + Recipients []string `json:"recipients,omitempty"` // email addresses, webhook URLs, user IDs + Format string `json:"format,omitempty"` // markdown | html | json | text + Template string `json:"template,omitempty"` // template name or inline template + Options map[string]interface{} `json:"options,omitempty"` // channel-specific options } // Task - planned task (structured, for execution) @@ -1313,6 +1323,10 @@ type Task struct { ExecutorID string `json:"executor_id"` Args []any `json:"args,omitempty"` + // Validation (defined in P2, used in P3) + ExpectedOutput string `json:"expected_output,omitempty"` // what the task should produce + ValidationRules []string `json:"validation_rules,omitempty"` // specific checks to perform + // Runtime Status TaskStatus `json:"status"` Order int `json:"order"` // execution order (0-based) @@ -1352,20 +1366,32 @@ const ( // TaskResult - task execution result type TaskResult struct { - TaskID string `json:"task_id"` - Success bool `json:"success"` - Output interface{} `json:"output,omitempty"` - Error string `json:"error,omitempty"` - Duration int64 `json:"duration_ms"` - Validated bool `json:"validated"` + TaskID string `json:"task_id"` + Success bool `json:"success"` + Output interface{} `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Duration int64 `json:"duration_ms"` + Validation *ValidationResult `json:"validation,omitempty"` // P3 validation result } -// DeliveryResult - delivery output +// ValidationResult - P3 semantic validation result +type ValidationResult struct { + Passed bool `json:"passed"` // overall validation passed + Score float64 `json:"score,omitempty"` // 0-1 confidence score + Issues []string `json:"issues,omitempty"` // what failed + Suggestions []string `json:"suggestions,omitempty"` // how to improve + Details string `json:"details,omitempty"` // detailed validation report +} + +// DeliveryResult - P4 delivery output type DeliveryResult struct { - Type DeliveryType `json:"type"` - Success bool `json:"success"` - Details interface{} `json:"details,omitempty"` - Error string `json:"error,omitempty"` + Type DeliveryType `json:"type"` + Success bool `json:"success"` + Recipients []string `json:"recipients,omitempty"` // who received + Content string `json:"content,omitempty"` // formatted content delivered + Details interface{} `json:"details,omitempty"` // channel-specific response + Error string `json:"error,omitempty"` + SentAt *time.Time `json:"sent_at,omitempty"` } // LearningEntry - knowledge to save diff --git a/agent/robot/executor/standard/goals.go b/agent/robot/executor/standard/goals.go index 8b8d55df..17d38bdc 100644 --- a/agent/robot/executor/standard/goals.go +++ b/agent/robot/executor/standard/goals.go @@ -1,6 +1,9 @@ package standard import ( + "fmt" + "strings" + robottypes "github.com/yaoapp/yao/agent/robot/types" ) @@ -12,14 +15,155 @@ import ( // - TriggerInput for human/event trigger // // Output: -// - Goals with markdown content listing prioritized objectives -// -// TODO: Implement real Agent call +// - Goals with markdown content and delivery info func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error { - e.simulateStreamDelay() - - exec.Goals = &robottypes.Goals{ - Content: "## Today's Goals\n\n1. [High] Review pending tasks\n2. [Medium] Process new requests\n3. [Low] Organize knowledge base", + // Get robot for identity and resources + robot := exec.GetRobot() + if robot == nil { + return fmt.Errorf("robot not found in execution") } + + // Get agent ID for goals phase + agentID := "__yao.goals" // default + if robot.Config != nil && robot.Config.Resources != nil { + agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseGoals) + } + + // Build prompt based on trigger type + formatter := NewInputFormatter() + var userContent string + + switch exec.TriggerType { + case robottypes.TriggerClock: + // For clock trigger: use InspirationReport from P0 + if exec.Inspiration != nil { + userContent = formatter.FormatInspirationReport(exec.Inspiration) + } else { + // Fallback: if no inspiration report, create minimal context + userContent = formatter.FormatClockContext( + robottypes.NewClockContext(exec.StartTime, ""), + robot, + ) + } + + case robottypes.TriggerHuman, robottypes.TriggerEvent: + // For human/event trigger: use TriggerInput directly + if exec.Input != nil { + userContent = formatter.FormatTriggerInput(exec.Input) + } + } + + // Add robot identity context if not already included + // For clock trigger with inspiration report, identity is not in the report + // For human/event trigger, identity provides context + if robot.Config != nil && robot.Config.Identity != nil { + if !strings.Contains(userContent, "## Robot Identity") { + userContent = formatter.FormatRobotIdentity(robot) + "\n\n" + userContent + } + } + + if userContent == "" { + return fmt.Errorf("no input available for goals generation") + } + + // Call agent + caller := NewAgentCaller() + result, err := caller.CallWithMessages(ctx, agentID, userContent) + if err != nil { + return fmt.Errorf("goals agent call failed: %w", err) + } + + // Parse response as JSON + // Goals Agent returns: { "content": "...", "delivery": {...} } + data, err := result.GetJSON() + if err != nil { + // Fallback: if not JSON, use raw text as content + content := result.GetText() + if content == "" { + return fmt.Errorf("goals agent returned empty response") + } + exec.Goals = &robottypes.Goals{ + Content: content, + } + return nil + } + + // Build Goals from JSON + exec.Goals = &robottypes.Goals{} + + // Extract content (markdown) + if content, ok := data["content"].(string); ok { + exec.Goals.Content = content + } + + // Extract delivery + if delivery, ok := data["delivery"].(map[string]interface{}); ok { + exec.Goals.Delivery = parseDelivery(delivery) + } + + // Validate: content is required + if exec.Goals.Content == "" { + return fmt.Errorf("goals agent returned empty content") + } + return nil } + +// parseDelivery converts map to DeliveryTarget struct +func parseDelivery(data map[string]interface{}) *robottypes.DeliveryTarget { + if data == nil { + return nil + } + + target := &robottypes.DeliveryTarget{} + + // Parse and validate type + if t, ok := data["type"].(string); ok { + deliveryType := robottypes.DeliveryType(t) + switch deliveryType { + case robottypes.DeliveryEmail, robottypes.DeliveryWebhook, + robottypes.DeliveryFile, robottypes.DeliveryNotify: + target.Type = deliveryType + default: + // Invalid type - still set it but caller should validate + target.Type = deliveryType + } + } + + // Parse recipients + if recipients, ok := data["recipients"].([]interface{}); ok { + for _, r := range recipients { + if s, ok := r.(string); ok { + target.Recipients = append(target.Recipients, s) + } + } + } + + // Parse format + if format, ok := data["format"].(string); ok { + target.Format = format + } + + // Parse template + if template, ok := data["template"].(string); ok { + target.Template = template + } + + // Parse options + if options, ok := data["options"].(map[string]interface{}); ok { + target.Options = options + } + + return target +} + +// IsValidDeliveryType checks if the delivery type is valid +func IsValidDeliveryType(t robottypes.DeliveryType) bool { + switch t { + case robottypes.DeliveryEmail, robottypes.DeliveryWebhook, + robottypes.DeliveryFile, robottypes.DeliveryNotify: + return true + default: + return false + } +} diff --git a/agent/robot/executor/standard/goals_test.go b/agent/robot/executor/standard/goals_test.go new file mode 100644 index 00000000..7d09cda4 --- /dev/null +++ b/agent/robot/executor/standard/goals_test.go @@ -0,0 +1,486 @@ +package standard_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + agentcontext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/robot/executor/standard" + "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/agent/testutils" +) + +// ============================================================================ +// P1 Goals Phase Tests +// ============================================================================ + +func TestRunGoalsBasic(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), testAuth()) + + t.Run("generates goals from inspiration report (clock trigger)", func(t *testing.T) { + // Create robot with goals agent configured + robot := createGoalsTestRobot(t, "robot.goals") + + // Create execution with inspiration report (from P0) + exec := createGoalsTestExecution(robot, types.TriggerClock) + exec.Inspiration = &types.InspirationReport{ + Clock: types.NewClockContext(time.Now(), ""), + Content: "## Summary\nToday is Monday morning. Focus on weekly planning.\n\n## Highlights\n- New sales leads arrived\n- Weekly report due Friday", + } + + // Run goals phase + e := standard.New() + err := e.RunGoals(ctx, exec, nil) + + require.NoError(t, err) + require.NotNil(t, exec.Goals) + assert.NotEmpty(t, exec.Goals.Content) + }) + + t.Run("includes priority markers in output", func(t *testing.T) { + robot := createGoalsTestRobot(t, "robot.goals") + exec := createGoalsTestExecution(robot, types.TriggerClock) + exec.Inspiration = &types.InspirationReport{ + Clock: types.NewClockContext(time.Now(), ""), + Content: "## Summary\nUrgent: Customer complaint needs attention.\n\n## Highlights\n- Critical bug reported\n- Regular maintenance scheduled", + } + + e := standard.New() + err := e.RunGoals(ctx, exec, nil) + + require.NoError(t, err) + content := exec.Goals.Content + + // Verify expected structure in markdown output + // Note: LLM output is non-deterministic, so we check for likely patterns + hasGoals := strings.Contains(content, "Goal") || + strings.Contains(content, "##") || + strings.Contains(content, "High") || + strings.Contains(content, "Normal") || + strings.Contains(content, "1.") + + assert.True(t, hasGoals, "should contain goals structure, got: %s", content) + }) +} + +func TestRunGoalsHumanTrigger(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), testAuth()) + + t.Run("generates goals from human intervention", func(t *testing.T) { + robot := createGoalsTestRobot(t, "robot.goals") + exec := createGoalsTestExecution(robot, types.TriggerHuman) + + // Set human intervention input + exec.Input = &types.TriggerInput{ + Action: "task.add", + UserID: "user-123", + Messages: []agentcontext.Message{ + {Role: agentcontext.RoleUser, Content: "Please analyze the Q4 sales data and prepare a summary report for the management meeting tomorrow."}, + }, + } + + e := standard.New() + err := e.RunGoals(ctx, exec, nil) + + require.NoError(t, err) + require.NotNil(t, exec.Goals) + assert.NotEmpty(t, exec.Goals.Content) + + // Goals should be related to the user request + content := strings.ToLower(exec.Goals.Content) + hasRelevantContent := strings.Contains(content, "sales") || + strings.Contains(content, "report") || + strings.Contains(content, "analysis") || + strings.Contains(content, "data") || + strings.Contains(content, "q4") + + assert.True(t, hasRelevantContent, "goals should relate to user request, got: %s", exec.Goals.Content) + }) + + t.Run("includes robot identity for human trigger", func(t *testing.T) { + robot := &types.Robot{ + MemberID: "test-robot-1", + TeamID: "test-team-1", + DisplayName: "Sales Analyst", + Config: &types.Config{ + Identity: &types.Identity{ + Role: "Sales Analyst", + Duties: []string{"Analyze sales data", "Generate reports"}, + Rules: []string{"Focus on actionable insights"}, + }, + Resources: &types.Resources{ + Phases: map[types.Phase]string{ + types.PhaseGoals: "robot.goals", + }, + }, + }, + } + exec := createGoalsTestExecution(robot, types.TriggerHuman) + exec.Input = &types.TriggerInput{ + Action: "instruct", + Messages: []agentcontext.Message{ + {Role: agentcontext.RoleUser, Content: "What should I focus on today?"}, + }, + } + + e := standard.New() + err := e.RunGoals(ctx, exec, nil) + + require.NoError(t, err) + assert.NotEmpty(t, exec.Goals.Content) + }) +} + +func TestRunGoalsEventTrigger(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), testAuth()) + + t.Run("generates goals from event trigger", func(t *testing.T) { + robot := createGoalsTestRobot(t, "robot.goals") + exec := createGoalsTestExecution(robot, types.TriggerEvent) + + // Set event input + exec.Input = &types.TriggerInput{ + Source: "webhook", + EventType: "lead.created", + Data: map[string]interface{}{ + "lead_id": "lead-456", + "company": "BigCorp Inc", + "contact_name": "John Smith", + "email": "john@bigcorp.com", + "interest": "Enterprise plan", + }, + } + + e := standard.New() + err := e.RunGoals(ctx, exec, nil) + + require.NoError(t, err) + require.NotNil(t, exec.Goals) + assert.NotEmpty(t, exec.Goals.Content) + + // Goals should be related to the event + content := strings.ToLower(exec.Goals.Content) + hasRelevantContent := strings.Contains(content, "lead") || + strings.Contains(content, "bigcorp") || + strings.Contains(content, "contact") || + strings.Contains(content, "follow") || + strings.Contains(content, "qualify") + + assert.True(t, hasRelevantContent, "goals should relate to event, got: %s", exec.Goals.Content) + }) +} + +func TestRunGoalsErrorHandling(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), testAuth()) + + t.Run("returns error when robot is nil", func(t *testing.T) { + exec := &types.Execution{ + ID: "test-exec-1", + TriggerType: types.TriggerClock, + } + // Don't set robot + + e := standard.New() + err := e.RunGoals(ctx, exec, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "robot not found") + }) + + t.Run("returns error when agent not found", func(t *testing.T) { + robot := &types.Robot{ + MemberID: "test-robot-1", + TeamID: "test-team-1", + Config: &types.Config{ + Identity: &types.Identity{Role: "Test"}, + Resources: &types.Resources{ + Phases: map[types.Phase]string{ + types.PhaseGoals: "non.existent.agent", + }, + }, + }, + } + exec := createGoalsTestExecution(robot, types.TriggerClock) + exec.Inspiration = &types.InspirationReport{ + Content: "Test content", + } + + e := standard.New() + err := e.RunGoals(ctx, exec, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "agent call failed") + }) + + t.Run("returns error when no input available and no identity", func(t *testing.T) { + // Robot without identity - should fail when no input is provided + robot := &types.Robot{ + MemberID: "test-robot-1", + TeamID: "test-team-1", + Config: &types.Config{ + // No Identity - so no fallback content + Resources: &types.Resources{ + Phases: map[types.Phase]string{ + types.PhaseGoals: "robot.goals", + }, + }, + }, + } + exec := createGoalsTestExecution(robot, types.TriggerHuman) + exec.Input = nil // No input + + e := standard.New() + err := e.RunGoals(ctx, exec, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no input available") + }) +} + +func TestRunGoalsFallbackBehavior(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), testAuth()) + + t.Run("falls back to clock context when no inspiration report", func(t *testing.T) { + robot := createGoalsTestRobot(t, "robot.goals") + exec := createGoalsTestExecution(robot, types.TriggerClock) + exec.Inspiration = nil // No inspiration report + + e := standard.New() + err := e.RunGoals(ctx, exec, nil) + + // Should still work with fallback clock context + require.NoError(t, err) + require.NotNil(t, exec.Goals) + assert.NotEmpty(t, exec.Goals.Content) + }) +} + +// ============================================================================ +// Delivery Parsing Tests +// ============================================================================ + +func TestParseDeliveryFromGoalsResponse(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), testAuth()) + + t.Run("parses delivery when agent returns it", func(t *testing.T) { + robot := createGoalsTestRobot(t, "robot.goals") + exec := createGoalsTestExecution(robot, types.TriggerHuman) + + // Request that explicitly asks for email delivery + exec.Input = &types.TriggerInput{ + Action: "task.add", + Messages: []agentcontext.Message{ + {Role: agentcontext.RoleUser, Content: "Prepare a sales report and send it to team@example.com via email"}, + }, + } + + e := standard.New() + err := e.RunGoals(ctx, exec, nil) + + require.NoError(t, err) + require.NotNil(t, exec.Goals) + assert.NotEmpty(t, exec.Goals.Content) + + // Delivery may or may not be present depending on LLM response + // If present, verify structure + if exec.Goals.Delivery != nil { + // Type should be valid if present + if exec.Goals.Delivery.Type != "" { + validTypes := []types.DeliveryType{ + types.DeliveryEmail, types.DeliveryWebhook, + types.DeliveryFile, types.DeliveryNotify, + } + found := false + for _, vt := range validTypes { + if exec.Goals.Delivery.Type == vt { + found = true + break + } + } + // Note: LLM might return non-standard types, we accept them but log + t.Logf("Delivery type: %s (valid: %v)", exec.Goals.Delivery.Type, found) + } + } + }) +} + +func TestDeliveryTypeValidation(t *testing.T) { + t.Run("valid delivery types", func(t *testing.T) { + validTypes := []types.DeliveryType{ + types.DeliveryEmail, + types.DeliveryWebhook, + types.DeliveryFile, + types.DeliveryNotify, + } + + for _, dt := range validTypes { + assert.True(t, standard.IsValidDeliveryType(dt), "should be valid: %s", dt) + } + }) + + t.Run("invalid delivery types", func(t *testing.T) { + invalidTypes := []types.DeliveryType{ + "invalid", + "sms", + "", + } + + for _, dt := range invalidTypes { + assert.False(t, standard.IsValidDeliveryType(dt), "should be invalid: %s", dt) + } + }) +} + +// ============================================================================ +// InputFormatter Tests for P1 +// ============================================================================ + +func TestInputFormatterFormatRobotIdentity(t *testing.T) { + formatter := standard.NewInputFormatter() + + t.Run("formats robot identity correctly", func(t *testing.T) { + robot := &types.Robot{ + MemberID: "test-robot", + Config: &types.Config{ + Identity: &types.Identity{ + Role: "Sales Analyst", + Duties: []string{"Analyze sales data", "Generate reports"}, + Rules: []string{"Be accurate", "Be concise"}, + }, + }, + } + + content := formatter.FormatRobotIdentity(robot) + + assert.Contains(t, content, "## Robot Identity") + assert.Contains(t, content, "Sales Analyst") + assert.Contains(t, content, "Analyze sales data") + assert.Contains(t, content, "Generate reports") + assert.Contains(t, content, "Be accurate") + assert.Contains(t, content, "Be concise") + }) + + t.Run("returns empty for nil robot", func(t *testing.T) { + content := formatter.FormatRobotIdentity(nil) + assert.Empty(t, content) + }) + + t.Run("returns empty for robot without config", func(t *testing.T) { + robot := &types.Robot{MemberID: "test"} + content := formatter.FormatRobotIdentity(robot) + assert.Empty(t, content) + }) + + t.Run("returns empty for robot without identity", func(t *testing.T) { + robot := &types.Robot{ + MemberID: "test", + Config: &types.Config{}, + } + content := formatter.FormatRobotIdentity(robot) + assert.Empty(t, content) + }) + + t.Run("handles identity with only role", func(t *testing.T) { + robot := &types.Robot{ + MemberID: "test", + Config: &types.Config{ + Identity: &types.Identity{ + Role: "Simple Bot", + }, + }, + } + + content := formatter.FormatRobotIdentity(robot) + + assert.Contains(t, content, "## Robot Identity") + assert.Contains(t, content, "Simple Bot") + assert.NotContains(t, content, "Duties") + assert.NotContains(t, content, "Rules") + }) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +// createGoalsTestRobot creates a test robot with specified goals agent +func createGoalsTestRobot(t *testing.T, agentID string) *types.Robot { + t.Helper() + return &types.Robot{ + MemberID: "test-robot-1", + TeamID: "test-team-1", + DisplayName: "Test Robot", + Config: &types.Config{ + Identity: &types.Identity{ + Role: "Test Assistant", + Duties: []string{"Testing", "Validation"}, + }, + Resources: &types.Resources{ + Phases: map[types.Phase]string{ + types.PhaseGoals: agentID, + }, + }, + }, + } +} + +// createGoalsTestExecution creates a test execution for goals phase +func createGoalsTestExecution(robot *types.Robot, trigger types.TriggerType) *types.Execution { + exec := &types.Execution{ + ID: "test-exec-goals-1", + MemberID: robot.MemberID, + TeamID: robot.TeamID, + TriggerType: trigger, + StartTime: time.Now(), + Status: types.ExecRunning, + Phase: types.PhaseGoals, + } + exec.SetRobot(robot) + return exec +} diff --git a/agent/robot/executor/standard/input.go b/agent/robot/executor/standard/input.go index 997c5265..c172ae9b 100644 --- a/agent/robot/executor/standard/input.go +++ b/agent/robot/executor/standard/input.go @@ -80,6 +80,36 @@ func (f *InputFormatter) FormatClockContext(clock *robottypes.ClockContext, robo return sb.String() } +// FormatRobotIdentity formats robot identity as user message content +// Used to provide context about the robot's role and duties +func (f *InputFormatter) FormatRobotIdentity(robot *robottypes.Robot) string { + if robot == nil || robot.Config == nil || robot.Config.Identity == nil { + return "" + } + + var sb strings.Builder + identity := robot.Config.Identity + + sb.WriteString("## Robot Identity\n\n") + sb.WriteString(fmt.Sprintf("- **Role**: %s\n", identity.Role)) + + if len(identity.Duties) > 0 { + sb.WriteString("- **Duties**:\n") + for _, duty := range identity.Duties { + sb.WriteString(fmt.Sprintf(" - %s\n", duty)) + } + } + + if len(identity.Rules) > 0 { + sb.WriteString("- **Rules**:\n") + for _, rule := range identity.Rules { + sb.WriteString(fmt.Sprintf(" - %s\n", rule)) + } + } + + return sb.String() +} + // FormatInspirationReport formats InspirationReport as user message content // Used by P1 (Goals) phase when trigger is Clock func (f *InputFormatter) FormatInspirationReport(report *robottypes.InspirationReport) string { @@ -273,7 +303,8 @@ func (f *InputFormatter) FormatTaskResults(results []robottypes.TaskResult) stri successCount := 0 failCount := 0 - validatedCount := 0 + validatedPassedCount := 0 + validatedTotalCount := 0 for _, result := range results { if result.Success { @@ -281,8 +312,11 @@ func (f *InputFormatter) FormatTaskResults(results []robottypes.TaskResult) stri } else { failCount++ } - if result.Validation != nil && result.Validation.Passed { - validatedCount++ + if result.Validation != nil { + validatedTotalCount++ + if result.Validation.Passed { + validatedPassedCount++ + } } sb.WriteString(fmt.Sprintf("### Task: %s\n\n", result.TaskID)) @@ -328,8 +362,8 @@ func (f *InputFormatter) FormatTaskResults(results []robottypes.TaskResult) stri } // Summary - sb.WriteString(fmt.Sprintf("## Summary\n\n- Total: %d tasks\n- Success: %d\n- Failed: %d\n- Validated: %d\n", - len(results), successCount, failCount, validatedCount)) + sb.WriteString(fmt.Sprintf("## Summary\n\n- Total: %d tasks\n- Success: %d\n- Failed: %d\n- Validated: %d/%d\n", + len(results), successCount, failCount, validatedPassedCount, validatedTotalCount)) return sb.String() } diff --git a/agent/robot/executor/standard/input_test.go b/agent/robot/executor/standard/input_test.go index ee67288e..6d44dd72 100644 --- a/agent/robot/executor/standard/input_test.go +++ b/agent/robot/executor/standard/input_test.go @@ -320,7 +320,7 @@ func TestInputFormatterFormatTaskResults(t *testing.T) { assert.Contains(t, result, "Total: 2 tasks") assert.Contains(t, result, "Success: 1") assert.Contains(t, result, "Failed: 1") - assert.Contains(t, result, "Validated: 1") + assert.Contains(t, result, "Validated: 1/2") }) t.Run("returns message for empty results", func(t *testing.T) { diff --git a/agent/robot/types/robot.go b/agent/robot/types/robot.go index 703918b5..05da4605 100644 --- a/agent/robot/types/robot.go +++ b/agent/robot/types/robot.go @@ -197,12 +197,8 @@ type CurrentState struct { type Goals struct { Content string `json:"content"` // markdown text - // Success criteria for P3 validation (semantic validation by LLM) - // Example: ["Report must include sales summary", "Growth rate must be calculated"] - SuccessCriteria []string `json:"success_criteria,omitempty"` - - // Delivery target for P4 (where to send results) - DeliveryTarget *DeliveryTarget `json:"delivery_target,omitempty"` + // Delivery for P4 (where to send results) + Delivery *DeliveryTarget `json:"delivery,omitempty"` } // DeliveryTarget - where to deliver results (defined in P1, used in P4) diff --git a/agent/robot/types/robot_test.go b/agent/robot/types/robot_test.go index 0328ce3d..503622c6 100644 --- a/agent/robot/types/robot_test.go +++ b/agent/robot/types/robot_test.go @@ -418,11 +418,7 @@ func TestTaskStructure(t *testing.T) { func TestGoalsStructure(t *testing.T) { goals := &types.Goals{ Content: "## Goals\n1. [High] Complete project\n2. [Normal] Review code", - SuccessCriteria: []string{ - "Project deliverables submitted", - "Code review completed with no blockers", - }, - DeliveryTarget: &types.DeliveryTarget{ + Delivery: &types.DeliveryTarget{ Type: types.DeliveryEmail, Recipients: []string{"team@example.com"}, Format: "markdown", @@ -431,10 +427,8 @@ func TestGoalsStructure(t *testing.T) { assert.Contains(t, goals.Content, "Goals") assert.Contains(t, goals.Content, "Complete project") - assert.Len(t, goals.SuccessCriteria, 2) - assert.Contains(t, goals.SuccessCriteria[0], "deliverables") - assert.NotNil(t, goals.DeliveryTarget) - assert.Equal(t, types.DeliveryEmail, goals.DeliveryTarget.Type) + assert.NotNil(t, goals.Delivery) + assert.Equal(t, types.DeliveryEmail, goals.Delivery.Type) } func TestTaskResultStructure(t *testing.T) { @@ -497,7 +491,7 @@ func TestDeliveryResultStructure(t *testing.T) { } func TestDeliveryTargetStructure(t *testing.T) { - target := &types.DeliveryTarget{ + delivery := &types.DeliveryTarget{ Type: types.DeliveryEmail, Recipients: []string{"team@example.com"}, Format: "markdown", @@ -507,10 +501,10 @@ func TestDeliveryTargetStructure(t *testing.T) { }, } - assert.Equal(t, types.DeliveryEmail, target.Type) - assert.Len(t, target.Recipients, 1) - assert.Equal(t, "markdown", target.Format) - assert.Equal(t, "weekly-report", target.Template) + assert.Equal(t, types.DeliveryEmail, delivery.Type) + assert.Len(t, delivery.Recipients, 1) + assert.Equal(t, "markdown", delivery.Format) + assert.Equal(t, "weekly-report", delivery.Template) } func TestLearningEntryStructure(t *testing.T) {