- Introduced manager ID and email fields in the Robot struct to facilitate mandatory delivery to managers. - Updated delivery routing logic to ensure manager email is always included if manager ID is set, improving delivery reliability. - Added JSON serialization handling in the Delivery Center to ensure compatibility with various data types. - Enhanced context ID generation by replacing time-based IDs with NanoID for improved uniqueness and consistency.
496 lines
14 KiB
Go
496 lines
14 KiB
Go
package standard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yaoapp/gou/model"
|
|
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
|
|
//
|
|
// Delivery logic:
|
|
// 1. Manager email: ALWAYS send to manager if manager_id is set (mandatory)
|
|
// 2. Additional targets: Append configured email/webhook/process targets
|
|
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")
|
|
}
|
|
|
|
// Build final delivery preferences by merging manager email + configured targets
|
|
prefs := buildDeliveryPreferences(robot)
|
|
if prefs == nil || !hasActiveChannels(prefs) {
|
|
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
|
|
}
|
|
|
|
// buildDeliveryPreferences builds the final delivery preferences by:
|
|
// 1. Always including manager email if manager_id is set (mandatory)
|
|
// 2. Appending all configured email/webhook/process targets
|
|
func buildDeliveryPreferences(robot *robottypes.Robot) *robottypes.DeliveryPreferences {
|
|
if robot == nil {
|
|
return nil
|
|
}
|
|
|
|
prefs := &robottypes.DeliveryPreferences{}
|
|
|
|
// Step 1: Get manager email (mandatory if manager_id is set)
|
|
managerEmail := robot.ManagerEmail
|
|
if managerEmail == "" && robot.ManagerID != "" {
|
|
managerEmail = getManagerEmail(robot.ManagerID)
|
|
if managerEmail != "" {
|
|
robot.ManagerEmail = managerEmail // Cache for future use
|
|
}
|
|
}
|
|
|
|
// Step 2: Build email targets (manager first, then configured targets)
|
|
var emailTargets []robottypes.EmailTarget
|
|
|
|
// Add manager email as first target (mandatory)
|
|
if managerEmail != "" {
|
|
emailTargets = append(emailTargets, robottypes.EmailTarget{
|
|
To: []string{managerEmail},
|
|
})
|
|
}
|
|
|
|
// Append configured email targets
|
|
if robot.Config != nil && robot.Config.Delivery != nil && robot.Config.Delivery.Email != nil {
|
|
for _, target := range robot.Config.Delivery.Email.Targets {
|
|
if len(target.To) > 0 {
|
|
emailTargets = append(emailTargets, target)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set email preference if we have any targets
|
|
if len(emailTargets) > 0 {
|
|
prefs.Email = &robottypes.EmailPreference{
|
|
Enabled: true,
|
|
Targets: emailTargets,
|
|
}
|
|
}
|
|
|
|
// Step 3: Copy webhook preferences from config (if enabled)
|
|
if robot.Config != nil && robot.Config.Delivery != nil && robot.Config.Delivery.Webhook != nil {
|
|
if robot.Config.Delivery.Webhook.Enabled && len(robot.Config.Delivery.Webhook.Targets) > 0 {
|
|
prefs.Webhook = robot.Config.Delivery.Webhook
|
|
}
|
|
}
|
|
|
|
// Step 4: Copy process preferences from config (if enabled)
|
|
if robot.Config != nil && robot.Config.Delivery != nil && robot.Config.Delivery.Process != nil {
|
|
if robot.Config.Delivery.Process.Enabled && len(robot.Config.Delivery.Process.Targets) > 0 {
|
|
prefs.Process = robot.Config.Delivery.Process
|
|
}
|
|
}
|
|
|
|
return prefs
|
|
}
|
|
|
|
// getManagerEmail retrieves the manager's email from __yao.member table by member_id
|
|
// manager_id in Robot refers to a member_id in __yao.member table
|
|
func getManagerEmail(managerID string) string {
|
|
if managerID == "" {
|
|
return ""
|
|
}
|
|
|
|
m := model.Select("__yao.member")
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
|
|
records, err := m.Get(model.QueryParam{
|
|
Select: []interface{}{"email"},
|
|
Wheres: []model.QueryWhere{
|
|
{Column: "member_id", Value: managerID},
|
|
},
|
|
Limit: 1,
|
|
})
|
|
if err != nil || len(records) == 0 {
|
|
return ""
|
|
}
|
|
|
|
if email, ok := records[0]["email"].(string); ok {
|
|
return email
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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()
|
|
}
|