- Introduced a new `Description` field in the `Task` struct for a human-readable task description, improving UI clarity. - Updated the `ParseTask` function to save the description from input data and convert it to a message if no explicit messages are provided. - Enhanced the `Executor` to update UI fields with localized messages during task execution phases, ensuring better user feedback. - Implemented a new method in the `ExecutionStore` to persist task status updates, allowing real-time UI updates. - Added unit tests to validate the new task description handling and UI updates during execution phases.
405 lines
11 KiB
Go
405 lines
11 KiB
Go
package standard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
|
)
|
|
|
|
// RunDelivery executes P4: Delivery phase
|
|
// Calls the Delivery Agent to generate content, then routes to Delivery Center
|
|
//
|
|
// Input:
|
|
// - Full execution context (P0-P3)
|
|
// - Robot config
|
|
//
|
|
// Output:
|
|
// - DeliveryResult with content and channel results
|
|
//
|
|
// Process:
|
|
// 1. Call Delivery Agent with full execution context
|
|
// 2. Agent generates DeliveryContent (summary, body, attachments)
|
|
// 3. Route content to Delivery Center for actual delivery
|
|
func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
|
// Get robot for identity and resources
|
|
robot := exec.GetRobot()
|
|
if robot == nil {
|
|
return fmt.Errorf("robot not found in execution")
|
|
}
|
|
|
|
// Update UI field with i18n
|
|
locale := getEffectiveLocale(robot, exec.Input)
|
|
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "generating_delivery"))
|
|
|
|
// Get agent ID for delivery phase
|
|
agentID := "__yao.delivery" // default
|
|
if robot.Config != nil && robot.Config.Resources != nil {
|
|
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseDelivery)
|
|
}
|
|
|
|
// Build input for Delivery Agent
|
|
formatter := NewInputFormatter()
|
|
userContent := formatter.FormatDeliveryInput(exec, robot)
|
|
|
|
if userContent == "" {
|
|
return fmt.Errorf("no content available for delivery generation")
|
|
}
|
|
|
|
// Call Delivery Agent
|
|
caller := NewAgentCaller()
|
|
result, err := caller.CallWithMessages(ctx, agentID, userContent)
|
|
if err != nil {
|
|
return fmt.Errorf("delivery agent (%s) call failed: %w", agentID, err)
|
|
}
|
|
|
|
// Parse response as JSON
|
|
// Delivery Agent returns: { "content": { "summary": "...", "body": "...", "attachments": [...] } }
|
|
data, err := result.GetJSON()
|
|
if err != nil {
|
|
// Fallback: if not JSON, create minimal content from raw text
|
|
content := result.GetText()
|
|
if content == "" {
|
|
return fmt.Errorf("delivery agent returned empty response")
|
|
}
|
|
exec.Delivery = &robottypes.DeliveryResult{
|
|
RequestID: generateRequestID(exec.ID),
|
|
Content: &robottypes.DeliveryContent{
|
|
Summary: truncateSummary(content, 200),
|
|
Body: content,
|
|
},
|
|
Success: true,
|
|
}
|
|
return e.routeToDeliveryCenter(ctx, exec, robot)
|
|
}
|
|
|
|
// Build DeliveryContent from JSON
|
|
content := parseDeliveryContent(data)
|
|
if content == nil {
|
|
return fmt.Errorf("delivery agent (%s) returned invalid content", agentID)
|
|
}
|
|
|
|
// Build DeliveryResult
|
|
exec.Delivery = &robottypes.DeliveryResult{
|
|
RequestID: generateRequestID(exec.ID),
|
|
Content: content,
|
|
Success: true,
|
|
}
|
|
|
|
// Route to Delivery Center for actual delivery
|
|
return e.routeToDeliveryCenter(ctx, exec, robot)
|
|
}
|
|
|
|
// routeToDeliveryCenter sends content to the Delivery Center for actual delivery
|
|
// The Delivery Center decides which channels to use based on robot/user preferences
|
|
func (e *Executor) routeToDeliveryCenter(ctx *robottypes.Context, exec *robottypes.Execution, robot *robottypes.Robot) error {
|
|
if exec.Delivery == nil || exec.Delivery.Content == nil {
|
|
return fmt.Errorf("no delivery content to route")
|
|
}
|
|
|
|
// Get delivery preferences from robot config
|
|
var prefs *robottypes.DeliveryPreferences
|
|
if robot.Config != nil {
|
|
prefs = robot.Config.Delivery
|
|
}
|
|
|
|
// If no preferences configured, skip delivery (content is still saved in exec.Delivery)
|
|
if prefs == nil || !hasActiveChannels(prefs) {
|
|
// No channels configured - mark as success but with no results
|
|
exec.Delivery.Success = true
|
|
return nil
|
|
}
|
|
|
|
// Update UI field to show delivery is in progress
|
|
locale := getEffectiveLocale(robot, exec.Input)
|
|
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "sending_delivery"))
|
|
|
|
// Create Delivery Center and execute
|
|
center := NewDeliveryCenter()
|
|
results, err := center.Deliver(ctx, exec.Delivery.Content, &robottypes.DeliveryContext{
|
|
MemberID: exec.MemberID,
|
|
ExecutionID: exec.ID,
|
|
TriggerType: exec.TriggerType,
|
|
TeamID: exec.TeamID,
|
|
}, prefs, robot)
|
|
|
|
// Update delivery result
|
|
exec.Delivery.Results = results
|
|
now := time.Now()
|
|
exec.Delivery.SentAt = &now
|
|
|
|
if err != nil {
|
|
exec.Delivery.Success = false
|
|
exec.Delivery.Error = err.Error()
|
|
return err
|
|
}
|
|
|
|
// Check if all channels succeeded
|
|
allSuccess := true
|
|
for _, r := range results {
|
|
if !r.Success {
|
|
allSuccess = false
|
|
break
|
|
}
|
|
}
|
|
exec.Delivery.Success = allSuccess
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseDeliveryContent parses the Delivery Agent response into DeliveryContent
|
|
func parseDeliveryContent(data map[string]interface{}) *robottypes.DeliveryContent {
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
|
|
// Try to get content object
|
|
contentData, ok := data["content"].(map[string]interface{})
|
|
if !ok {
|
|
// Fallback: maybe the data itself is the content
|
|
contentData = data
|
|
}
|
|
|
|
content := &robottypes.DeliveryContent{}
|
|
|
|
// Parse summary
|
|
if summary, ok := contentData["summary"].(string); ok {
|
|
content.Summary = summary
|
|
}
|
|
|
|
// Parse body
|
|
if body, ok := contentData["body"].(string); ok {
|
|
content.Body = body
|
|
}
|
|
|
|
// Parse attachments
|
|
if attachments, ok := contentData["attachments"].([]interface{}); ok {
|
|
for _, att := range attachments {
|
|
if attMap, ok := att.(map[string]interface{}); ok {
|
|
attachment := parseDeliveryAttachment(attMap)
|
|
if attachment != nil {
|
|
content.Attachments = append(content.Attachments, *attachment)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate: at least summary or body should be present
|
|
if content.Summary == "" && content.Body == "" {
|
|
return nil
|
|
}
|
|
|
|
return content
|
|
}
|
|
|
|
// parseDeliveryAttachment parses a single attachment from the agent response
|
|
func parseDeliveryAttachment(data map[string]interface{}) *robottypes.DeliveryAttachment {
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
|
|
att := &robottypes.DeliveryAttachment{}
|
|
|
|
if title, ok := data["title"].(string); ok {
|
|
att.Title = title
|
|
}
|
|
if desc, ok := data["description"].(string); ok {
|
|
att.Description = desc
|
|
}
|
|
if taskID, ok := data["task_id"].(string); ok {
|
|
att.TaskID = taskID
|
|
}
|
|
if file, ok := data["file"].(string); ok {
|
|
att.File = file
|
|
}
|
|
|
|
// At minimum, need title and file
|
|
if att.Title == "" || att.File == "" {
|
|
return nil
|
|
}
|
|
|
|
return att
|
|
}
|
|
|
|
// generateRequestID generates a unique request ID for delivery tracking
|
|
func generateRequestID(execID string) string {
|
|
return fmt.Sprintf("dlv-%s-%d", execID, time.Now().UnixNano()%1000000)
|
|
}
|
|
|
|
// getTaskDescription extracts a description from task messages
|
|
func getTaskDescription(task robottypes.Task) string {
|
|
if len(task.Messages) == 0 {
|
|
return task.GoalRef
|
|
}
|
|
|
|
// Try to get text from first message
|
|
for _, msg := range task.Messages {
|
|
if content, ok := msg.Content.(string); ok && content != "" {
|
|
// Truncate if too long
|
|
if len(content) > 100 {
|
|
return content[:97] + "..."
|
|
}
|
|
return content
|
|
}
|
|
}
|
|
|
|
// Fallback to goal reference
|
|
if task.GoalRef != "" {
|
|
return task.GoalRef
|
|
}
|
|
|
|
return "Task " + task.ID
|
|
}
|
|
|
|
// truncateSummary truncates text to maxLen characters
|
|
func truncateSummary(text string, maxLen int) string {
|
|
if len(text) <= maxLen {
|
|
return text
|
|
}
|
|
// Find last space before maxLen to avoid cutting words
|
|
truncated := text[:maxLen]
|
|
if idx := strings.LastIndex(truncated, " "); idx > maxLen/2 {
|
|
return truncated[:idx] + "..."
|
|
}
|
|
return truncated + "..."
|
|
}
|
|
|
|
// hasActiveChannels checks if any delivery channel is configured
|
|
func hasActiveChannels(prefs *robottypes.DeliveryPreferences) bool {
|
|
if prefs == nil {
|
|
return false
|
|
}
|
|
if prefs.Email != nil && prefs.Email.Enabled && len(prefs.Email.Targets) > 0 {
|
|
return true
|
|
}
|
|
if prefs.Webhook != nil && prefs.Webhook.Enabled && len(prefs.Webhook.Targets) > 0 {
|
|
return true
|
|
}
|
|
if prefs.Process != nil && prefs.Process.Enabled && len(prefs.Process.Targets) > 0 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// FormatDeliveryInput formats the full execution context for the Delivery Agent
|
|
func (f *InputFormatter) FormatDeliveryInput(exec *robottypes.Execution, robot *robottypes.Robot) string {
|
|
if exec == nil {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
|
|
// Robot identity
|
|
if robot != nil && robot.Config != nil && robot.Config.Identity != nil {
|
|
sb.WriteString("## Robot Identity\n\n")
|
|
sb.WriteString(fmt.Sprintf("- **Role**: %s\n", robot.Config.Identity.Role))
|
|
if len(robot.Config.Identity.Duties) > 0 {
|
|
sb.WriteString("- **Duties**: ")
|
|
sb.WriteString(strings.Join(robot.Config.Identity.Duties, ", "))
|
|
sb.WriteString("\n")
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Trigger type
|
|
sb.WriteString("## Execution Context\n\n")
|
|
sb.WriteString(fmt.Sprintf("- **Trigger**: %s\n", exec.TriggerType))
|
|
sb.WriteString(fmt.Sprintf("- **Status**: %s\n", exec.Status))
|
|
sb.WriteString(fmt.Sprintf("- **Start Time**: %s\n", exec.StartTime.Format("2006-01-02 15:04:05")))
|
|
if exec.EndTime != nil {
|
|
duration := exec.EndTime.Sub(exec.StartTime)
|
|
sb.WriteString(fmt.Sprintf("- **Duration**: %s\n", duration.String()))
|
|
}
|
|
sb.WriteString("\n")
|
|
|
|
// Inspiration (P0) - for clock trigger
|
|
if exec.Inspiration != nil && exec.Inspiration.Content != "" {
|
|
sb.WriteString("## Inspiration (P0)\n\n")
|
|
sb.WriteString(exec.Inspiration.Content)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
|
|
// Goals (P1)
|
|
if exec.Goals != nil && exec.Goals.Content != "" {
|
|
sb.WriteString("## Goals (P1)\n\n")
|
|
sb.WriteString(exec.Goals.Content)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
|
|
// Tasks (P2)
|
|
if len(exec.Tasks) > 0 {
|
|
sb.WriteString("## Tasks (P2)\n\n")
|
|
for i, task := range exec.Tasks {
|
|
// Extract task description from messages if available
|
|
taskDesc := getTaskDescription(task)
|
|
sb.WriteString(fmt.Sprintf("%d. **%s** - %s\n", i+1, task.ID, taskDesc))
|
|
sb.WriteString(fmt.Sprintf(" - Executor: %s (%s)\n", task.ExecutorID, task.ExecutorType))
|
|
sb.WriteString(fmt.Sprintf(" - Status: %s\n", task.Status))
|
|
if task.ExpectedOutput != "" {
|
|
sb.WriteString(fmt.Sprintf(" - Expected: %s\n", task.ExpectedOutput))
|
|
}
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Results (P3) - detailed
|
|
if len(exec.Results) > 0 {
|
|
sb.WriteString("## Results (P3)\n\n")
|
|
|
|
successCount := 0
|
|
failCount := 0
|
|
|
|
for _, result := range exec.Results {
|
|
if result.Success {
|
|
successCount++
|
|
sb.WriteString(fmt.Sprintf("### ✓ Task: %s\n\n", result.TaskID))
|
|
} else {
|
|
failCount++
|
|
sb.WriteString(fmt.Sprintf("### ✗ Task: %s\n\n", result.TaskID))
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("- **Duration**: %dms\n", result.Duration))
|
|
|
|
// Validation
|
|
if result.Validation != nil {
|
|
if result.Validation.Passed {
|
|
sb.WriteString(fmt.Sprintf("- **Validation**: ✓ Passed (score: %.2f)\n", result.Validation.Score))
|
|
} else {
|
|
sb.WriteString("- **Validation**: ✗ Failed\n")
|
|
if len(result.Validation.Issues) > 0 {
|
|
for _, issue := range result.Validation.Issues {
|
|
sb.WriteString(fmt.Sprintf(" - %s\n", issue))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Output
|
|
if result.Output != nil {
|
|
sb.WriteString("\n**Output**:\n")
|
|
if output, err := json.MarshalIndent(result.Output, "", " "); err == nil {
|
|
sb.WriteString("```json\n")
|
|
sb.WriteString(string(output))
|
|
sb.WriteString("\n```\n")
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("%v\n", result.Output))
|
|
}
|
|
}
|
|
|
|
// Error
|
|
if result.Error != "" {
|
|
sb.WriteString(fmt.Sprintf("\n**Error**: %s\n", result.Error))
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Summary
|
|
sb.WriteString(fmt.Sprintf("### Summary\n\n- **Total Tasks**: %d\n- **Succeeded**: %d\n- **Failed**: %d\n\n",
|
|
len(exec.Results), successCount, failCount))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|