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.
This commit is contained in:
parent
41e0544aba
commit
2d86c9caad
8 changed files with 767 additions and 56 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
486
agent/robot/executor/standard/goals_test.go
Normal file
486
agent/robot/executor/standard/goals_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue