- Added support for reading files from workspace URIs in the delivery process, allowing for more flexible attachment management. - Introduced a new `convertWorkspaceAttachment` function to handle workspace-based file retrieval and integration into messenger attachments. - Updated the `AgentCaller` to include execution mode in the context, improving task execution tracking. - Enhanced the `RunDelivery` method to utilize workspace manifests for delivery input, reducing token usage and improving efficiency. - Implemented locale handling in various request structures to support multi-language capabilities in user interfaces.
539 lines
15 KiB
Go
539 lines
15 KiB
Go
package standard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yaoapp/gou/model"
|
|
"github.com/yaoapp/gou/process"
|
|
kunlog "github.com/yaoapp/kun/log"
|
|
robotevents "github.com/yaoapp/yao/agent/robot/events"
|
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
|
"github.com/yaoapp/yao/event"
|
|
"github.com/yaoapp/yao/workspace"
|
|
)
|
|
|
|
// RunDelivery executes P4: Delivery phase
|
|
//
|
|
// Process:
|
|
// 1. Call Delivery Agent with full execution context
|
|
// 2. Agent generates DeliveryContent (summary, body, attachments)
|
|
// 3. Push delivery event for asynchronous routing via handlers
|
|
func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
|
robot := exec.GetRobot()
|
|
if robot == nil {
|
|
return fmt.Errorf("robot not found in execution")
|
|
}
|
|
|
|
locale := getEffectiveLocale(robot, exec.Input)
|
|
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "generating_delivery"))
|
|
|
|
// Get agent ID for delivery phase (per-robot config > global Uses > empty)
|
|
agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseDelivery)
|
|
if agentID == "" {
|
|
return fmt.Errorf("no Delivery Agent configured (set uses.delivery in agent.yml or resources.phases in robot config)")
|
|
}
|
|
|
|
formatter := NewInputFormatter()
|
|
|
|
// Try workspace-based delivery input (manifest summaries instead of raw output inline)
|
|
var userContent string
|
|
if robot.Workspace != "" {
|
|
wsm := workspace.M()
|
|
if wsm != nil {
|
|
wsFS, err := wsm.FS(ctx, robot.Workspace)
|
|
if err == nil {
|
|
execDir := path.Join("robots", robot.MemberID, exec.ID)
|
|
data, err := wsFS.ReadFile(path.Join(execDir, "manifest.json"))
|
|
if err == nil {
|
|
var manifest Manifest
|
|
if json.Unmarshal(data, &manifest) == nil {
|
|
userContent = formatter.FormatDeliveryInputWithManifest(exec, robot, &manifest, robot.Workspace, execDir, locale)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to legacy full-output inline
|
|
if userContent == "" {
|
|
userContent = formatter.FormatDeliveryInput(exec, robot)
|
|
}
|
|
|
|
if userContent == "" {
|
|
return fmt.Errorf("no content available for delivery generation")
|
|
}
|
|
|
|
caller := NewAgentCaller()
|
|
caller.Workspace = robot.Workspace
|
|
result, err := caller.CallWithMessages(ctx, agentID, userContent)
|
|
if err != nil {
|
|
return fmt.Errorf("delivery agent (%s) call failed: %w", agentID, err)
|
|
}
|
|
|
|
data, err := result.GetJSON()
|
|
if err != nil {
|
|
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.pushDeliveryEvent(ctx, exec, robot)
|
|
}
|
|
|
|
content := parseDeliveryContent(data)
|
|
if content == nil {
|
|
return fmt.Errorf("delivery agent (%s) returned invalid content", agentID)
|
|
}
|
|
|
|
exec.Delivery = &robottypes.DeliveryResult{
|
|
RequestID: generateRequestID(exec.ID),
|
|
Content: content,
|
|
Success: true,
|
|
}
|
|
|
|
return e.pushDeliveryEvent(ctx, exec, robot)
|
|
}
|
|
|
|
// pushDeliveryEvent pushes a delivery event to the event bus.
|
|
// Registered handlers (see events/handlers.go) route to email/webhook/process channels.
|
|
func (e *Executor) pushDeliveryEvent(ctx *robottypes.Context, exec *robottypes.Execution, robot *robottypes.Robot) error {
|
|
prefs := buildDeliveryPreferences(robot)
|
|
|
|
chatID := exec.ChatID
|
|
var extra map[string]any
|
|
if exec.Input != nil && exec.Input.Data != nil {
|
|
if sourceChatID, ok := exec.Input.Data["chat_id"].(string); ok && sourceChatID != "" {
|
|
if channel, ok := exec.Input.Data["channel"].(string); ok && channel != "" {
|
|
chatID = channel + ":" + sourceChatID
|
|
}
|
|
}
|
|
if e, ok := exec.Input.Data["extra"].(map[string]any); ok {
|
|
extra = e
|
|
}
|
|
}
|
|
|
|
eventCtx := ctx.Context
|
|
if ctx.Auth != nil {
|
|
eventCtx = event.WithAuth(eventCtx, &process.AuthorizedInfo{
|
|
UserID: ctx.Auth.UserID,
|
|
TeamID: ctx.Auth.TeamID,
|
|
Subject: ctx.Auth.Subject,
|
|
})
|
|
}
|
|
|
|
_, err := event.Push(eventCtx, robotevents.Delivery, robotevents.DeliveryPayload{
|
|
ExecutionID: exec.ID,
|
|
MemberID: exec.MemberID,
|
|
TeamID: exec.TeamID,
|
|
ChatID: chatID,
|
|
Content: exec.Delivery.Content,
|
|
Preferences: prefs,
|
|
Extra: extra,
|
|
})
|
|
if err != nil {
|
|
kunlog.Error("delivery event push failed: execution=%s error=%v", exec.ID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseDeliveryContent parses the Delivery Agent response into DeliveryContent
|
|
func parseDeliveryContent(data map[string]interface{}) *robottypes.DeliveryContent {
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
|
|
contentData, ok := data["content"].(map[string]interface{})
|
|
if !ok {
|
|
contentData = data
|
|
}
|
|
|
|
content := &robottypes.DeliveryContent{}
|
|
|
|
if summary, ok := contentData["summary"].(string); ok {
|
|
content.Summary = summary
|
|
}
|
|
if body, ok := contentData["body"].(string); ok {
|
|
content.Body = body
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if content.Summary == "" && content.Body == "" {
|
|
return nil
|
|
}
|
|
|
|
return content
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if att.Title == "" || att.File == "" {
|
|
return nil
|
|
}
|
|
|
|
return att
|
|
}
|
|
|
|
func generateRequestID(execID string) string {
|
|
return fmt.Sprintf("dlv-%s-%d", execID, time.Now().UnixNano()%1000000)
|
|
}
|
|
|
|
func getTaskDescription(task robottypes.Task) string {
|
|
if len(task.Messages) == 0 {
|
|
return task.GoalRef
|
|
}
|
|
|
|
for _, msg := range task.Messages {
|
|
if content, ok := msg.Content.(string); ok && content != "" {
|
|
if len(content) > 100 {
|
|
return content[:97] + "..."
|
|
}
|
|
return content
|
|
}
|
|
}
|
|
|
|
if task.GoalRef != "" {
|
|
return task.GoalRef
|
|
}
|
|
|
|
return "Task " + task.ID
|
|
}
|
|
|
|
func truncateSummary(text string, maxLen int) string {
|
|
if len(text) <= maxLen {
|
|
return text
|
|
}
|
|
truncated := text[:maxLen]
|
|
if idx := strings.LastIndex(truncated, " "); idx > maxLen/2 {
|
|
return truncated[:idx] + "..."
|
|
}
|
|
return truncated + "..."
|
|
}
|
|
|
|
func buildDeliveryPreferences(robot *robottypes.Robot) *robottypes.DeliveryPreferences {
|
|
if robot == nil {
|
|
return nil
|
|
}
|
|
|
|
prefs := &robottypes.DeliveryPreferences{}
|
|
|
|
managerEmail := robot.ManagerEmail
|
|
if managerEmail == "" && robot.ManagerID != "" {
|
|
managerEmail = getManagerEmail(robot.ManagerID)
|
|
if managerEmail != "" {
|
|
robot.ManagerEmail = managerEmail
|
|
}
|
|
}
|
|
|
|
var emailTargets []robottypes.EmailTarget
|
|
|
|
if managerEmail != "" {
|
|
emailTargets = append(emailTargets, robottypes.EmailTarget{
|
|
To: []string{managerEmail},
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(emailTargets) > 0 {
|
|
prefs.Email = &robottypes.EmailPreference{
|
|
Enabled: true,
|
|
Targets: emailTargets,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
|
|
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")
|
|
}
|
|
|
|
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")
|
|
|
|
if exec.Inspiration != nil && exec.Inspiration.Content != "" {
|
|
sb.WriteString("## Inspiration (P0)\n\n")
|
|
sb.WriteString(exec.Inspiration.Content)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
|
|
if exec.Goals != nil && exec.Goals.Content != "" {
|
|
sb.WriteString("## Goals (P1)\n\n")
|
|
sb.WriteString(exec.Goals.Content)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
|
|
if len(exec.Tasks) > 0 {
|
|
sb.WriteString("## Tasks (P2)\n\n")
|
|
for i, task := range exec.Tasks {
|
|
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")
|
|
}
|
|
|
|
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))
|
|
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
if result.Error != "" {
|
|
sb.WriteString(fmt.Sprintf("\n**Error**: %s\n", result.Error))
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// FormatDeliveryInputWithManifest formats delivery input using workspace manifest summaries
|
|
// instead of inlining full task outputs. This drastically reduces token usage.
|
|
func (f *InputFormatter) FormatDeliveryInputWithManifest(
|
|
exec *robottypes.Execution,
|
|
robot *robottypes.Robot,
|
|
manifest *Manifest,
|
|
wsID string,
|
|
execDir string,
|
|
locale string,
|
|
) string {
|
|
if exec == nil || manifest == nil {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
|
|
// Robot Identity (same as legacy)
|
|
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")
|
|
}
|
|
|
|
sb.WriteString("## Execution Context\n\n")
|
|
sb.WriteString(fmt.Sprintf("- **Trigger**: %s\n", exec.TriggerType))
|
|
sb.WriteString(fmt.Sprintf("- **Status**: %s\n", exec.Status))
|
|
if locale != "" {
|
|
sb.WriteString(fmt.Sprintf("- **Language**: %s\n", locale))
|
|
}
|
|
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")
|
|
|
|
sb.WriteString("## Goals\n\n")
|
|
sb.WriteString(manifest.Goals)
|
|
sb.WriteString("\n\n")
|
|
|
|
// Results from manifest summaries
|
|
sb.WriteString("## Results (P3)\n\n")
|
|
successCount := 0
|
|
failCount := 0
|
|
|
|
for _, t := range manifest.Tasks {
|
|
if t.Status == "completed" {
|
|
successCount++
|
|
sb.WriteString(fmt.Sprintf("### ✓ Task: %s\n\n", t.ID))
|
|
} else if t.Status == "failed" {
|
|
failCount++
|
|
sb.WriteString(fmt.Sprintf("### ✗ Task: %s\n\n", t.ID))
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("- **Description**: %s\n", t.Description))
|
|
sb.WriteString(fmt.Sprintf("- **Executor**: %s (%s)\n", t.Executor, t.ExecutorType))
|
|
|
|
if t.Summary != "" {
|
|
sb.WriteString(fmt.Sprintf("- **Summary**: %s\n", t.Summary))
|
|
}
|
|
if len(t.KeyOutputs) > 0 {
|
|
sb.WriteString(fmt.Sprintf("- **Key Outputs**: %s\n", strings.Join(t.KeyOutputs, ", ")))
|
|
}
|
|
|
|
// File references — use existing URI if present, otherwise build from execDir
|
|
if len(t.Files) > 0 {
|
|
sb.WriteString("- **Artifacts**:\n")
|
|
for _, file := range t.Files {
|
|
uri := file.URI
|
|
if uri == "" {
|
|
uri = fmt.Sprintf("workspace://%s/%s/%s/%s", wsID, execDir, t.ID, file.Name)
|
|
}
|
|
sb.WriteString(fmt.Sprintf(" - [%s](%s)", file.Name, uri))
|
|
if file.Desc != "" {
|
|
sb.WriteString(fmt.Sprintf(" — %s", file.Desc))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Full output file reference
|
|
outputURI := fmt.Sprintf("workspace://%s/%s/%s.output.md", wsID, execDir, t.ID)
|
|
sb.WriteString(fmt.Sprintf("- **Full Output**: [%s.output.md](%s)\n", t.ID, outputURI))
|
|
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("### Summary\n\n- **Total Tasks**: %d\n- **Succeeded**: %d\n- **Failed**: %d\n\n",
|
|
successCount+failCount, successCount, failCount))
|
|
|
|
return sb.String()
|
|
}
|