- 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.
479 lines
14 KiB
Go
479 lines
14 KiB
Go
package standard
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
kunlog "github.com/yaoapp/kun/log"
|
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
|
)
|
|
|
|
// RunTasks executes P2: Tasks phase
|
|
// Calls the Tasks Agent to break down goals into executable tasks
|
|
//
|
|
// Input:
|
|
// - Goals (from P1) with markdown content
|
|
// - Available resources (Agents, MCP tools, KB, DB)
|
|
//
|
|
// Output:
|
|
// - List of Task objects with executor assignments, expected outputs, and validation rules
|
|
func (e *Executor) RunTasks(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
|
// §18.2: confirming phase may have already populated Tasks — skip regeneration
|
|
if len(exec.Tasks) > 0 {
|
|
return nil
|
|
}
|
|
|
|
// Get robot for 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, "breaking_down_tasks"))
|
|
|
|
// Validate: Goals must exist (from P1)
|
|
if exec.Goals == nil || exec.Goals.Content == "" {
|
|
return fmt.Errorf("goals not available for task planning")
|
|
}
|
|
|
|
// Get agent ID for tasks phase (per-robot config > global Uses > empty)
|
|
agentID := robottypes.ResolvePhaseAgent(robot.Config, robottypes.PhaseTasks)
|
|
if agentID == "" {
|
|
return fmt.Errorf("no Tasks Agent configured (set uses.tasks in agent.yml or resources.phases in robot config)")
|
|
}
|
|
|
|
// Build prompt with goals and available resources
|
|
formatter := NewInputFormatter()
|
|
userContent := formatter.FormatGoals(exec.Goals, robot)
|
|
|
|
if userContent == "" {
|
|
return fmt.Errorf("tasks agent (%s) received empty input for task planning", agentID)
|
|
}
|
|
|
|
// Call agent
|
|
caller := NewAgentCaller()
|
|
caller.log = newExecLogger(robot, exec.ID)
|
|
caller.Workspace = robot.Workspace
|
|
result, err := caller.CallWithMessages(ctx, agentID, userContent)
|
|
if err != nil {
|
|
return fmt.Errorf("tasks agent (%s) call failed: %w", agentID, err)
|
|
}
|
|
|
|
// Parse response as JSON
|
|
// Tasks Agent returns: { "tasks": [...] }
|
|
data, err := result.GetJSON()
|
|
if err != nil {
|
|
return fmt.Errorf("tasks agent (%s) returned invalid JSON: %w", agentID, err)
|
|
}
|
|
|
|
// Extract tasks array
|
|
tasksData, ok := data["tasks"].([]interface{})
|
|
if !ok || len(tasksData) == 0 {
|
|
return fmt.Errorf("tasks agent (%s) returned no tasks", agentID)
|
|
}
|
|
|
|
// Parse tasks
|
|
tasks, err := ParseTasks(tasksData)
|
|
if err != nil {
|
|
return fmt.Errorf("tasks agent (%s) returned invalid task structure: %w", agentID, err)
|
|
}
|
|
|
|
// Normalize executor IDs and types against available resources
|
|
NormalizeTaskExecutors(tasks, robot)
|
|
|
|
// Validate tasks
|
|
if err := ValidateTasks(tasks); err != nil {
|
|
return fmt.Errorf("tasks validation failed: %w", err)
|
|
}
|
|
|
|
exec.Tasks = tasks
|
|
|
|
// Log task overview for developer observability
|
|
el := newExecLogger(robot, exec.ID)
|
|
el.logTaskOverview(tasks)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ParseTasks converts raw JSON array to []Task
|
|
// Tasks are sorted by Order field after parsing
|
|
func ParseTasks(data []interface{}) ([]robottypes.Task, error) {
|
|
tasks := make([]robottypes.Task, 0, len(data))
|
|
|
|
for i, item := range data {
|
|
taskMap, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("task %d is not a valid object", i)
|
|
}
|
|
|
|
task, err := ParseTask(taskMap, i)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("task %d: %w", i, err)
|
|
}
|
|
|
|
tasks = append(tasks, *task)
|
|
}
|
|
|
|
// Sort tasks by Order field to ensure correct execution sequence
|
|
SortTasksByOrder(tasks)
|
|
|
|
return tasks, nil
|
|
}
|
|
|
|
// ParseTask converts a map to Task struct
|
|
func ParseTask(data map[string]interface{}, index int) (*robottypes.Task, error) {
|
|
task := &robottypes.Task{
|
|
Status: robottypes.TaskPending,
|
|
Order: index,
|
|
}
|
|
|
|
// Required: id
|
|
if id, ok := data["id"].(string); ok && id != "" {
|
|
task.ID = id
|
|
} else {
|
|
task.ID = fmt.Sprintf("task-%03d", index+1)
|
|
}
|
|
|
|
// Required: executor_type
|
|
if execType, ok := data["executor_type"].(string); ok {
|
|
task.ExecutorType = ParseExecutorType(execType)
|
|
} else {
|
|
return nil, fmt.Errorf("missing executor_type")
|
|
}
|
|
|
|
// Required: executor_id
|
|
if execID, ok := data["executor_id"].(string); ok && execID != "" {
|
|
task.ExecutorID = execID
|
|
} else {
|
|
return nil, fmt.Errorf("missing executor_id")
|
|
}
|
|
|
|
// Optional: goal_ref
|
|
if goalRef, ok := data["goal_ref"].(string); ok {
|
|
task.GoalRef = goalRef
|
|
}
|
|
|
|
// Optional: source (default to auto)
|
|
if source, ok := data["source"].(string); ok {
|
|
task.Source = robottypes.TaskSource(source)
|
|
} else {
|
|
task.Source = robottypes.TaskSourceAuto
|
|
}
|
|
|
|
// Optional: order (override default)
|
|
if order, ok := data["order"].(float64); ok {
|
|
task.Order = int(order)
|
|
}
|
|
|
|
// Optional: messages (task instructions)
|
|
if messages, ok := data["messages"].([]interface{}); ok {
|
|
task.Messages = ParseMessages(messages)
|
|
}
|
|
|
|
// Optional: description - save to Description field and convert to message if no messages
|
|
if desc, ok := data["description"].(string); ok && desc != "" {
|
|
task.Description = desc
|
|
// Also convert to Messages for execution if no explicit messages provided
|
|
if len(task.Messages) == 0 {
|
|
task.Messages = []agentcontext.Message{
|
|
{Role: agentcontext.RoleUser, Content: desc},
|
|
}
|
|
}
|
|
}
|
|
|
|
// Optional: args
|
|
if args, ok := data["args"].([]interface{}); ok {
|
|
task.Args = make([]any, len(args))
|
|
copy(task.Args, args)
|
|
}
|
|
|
|
// MCP-specific fields (required when executor_type is "mcp")
|
|
if mcpServer, ok := data["mcp_server"].(string); ok {
|
|
task.MCPServer = mcpServer
|
|
}
|
|
if mcpTool, ok := data["mcp_tool"].(string); ok {
|
|
task.MCPTool = mcpTool
|
|
}
|
|
|
|
// Optional: expected_output (for P3 validation)
|
|
if expectedOutput, ok := data["expected_output"].(string); ok {
|
|
task.ExpectedOutput = expectedOutput
|
|
}
|
|
|
|
// Optional: validation_rules (for P3 validation)
|
|
if rules, ok := data["validation_rules"].([]interface{}); ok {
|
|
task.ValidationRules = make([]string, 0, len(rules))
|
|
for _, r := range rules {
|
|
if s, ok := r.(string); ok {
|
|
task.ValidationRules = append(task.ValidationRules, s)
|
|
}
|
|
}
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
// ParseMessages converts raw message array to []Message
|
|
func ParseMessages(data []interface{}) []agentcontext.Message {
|
|
messages := make([]agentcontext.Message, 0, len(data))
|
|
|
|
for _, item := range data {
|
|
msgMap, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
msg := agentcontext.Message{}
|
|
|
|
// Role
|
|
if role, ok := msgMap["role"].(string); ok {
|
|
msg.Role = agentcontext.MessageRole(role)
|
|
} else {
|
|
msg.Role = agentcontext.RoleUser
|
|
}
|
|
|
|
// Content
|
|
if content, ok := msgMap["content"].(string); ok {
|
|
msg.Content = content
|
|
} else if content, ok := msgMap["content"]; ok {
|
|
// Handle non-string content (multimodal)
|
|
msg.Content = content
|
|
}
|
|
|
|
if msg.Content != nil {
|
|
messages = append(messages, msg)
|
|
}
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
// ParseExecutorType converts string to ExecutorType
|
|
func ParseExecutorType(s string) robottypes.ExecutorType {
|
|
switch s {
|
|
case "agent", "assistant":
|
|
return robottypes.ExecutorAssistant
|
|
case "mcp":
|
|
return robottypes.ExecutorMCP
|
|
case "process":
|
|
return robottypes.ExecutorProcess
|
|
default:
|
|
return robottypes.ExecutorAssistant // default to assistant
|
|
}
|
|
}
|
|
|
|
// ValidateTasks validates the task list
|
|
func ValidateTasks(tasks []robottypes.Task) error {
|
|
if len(tasks) == 0 {
|
|
return fmt.Errorf("no tasks generated")
|
|
}
|
|
|
|
seenIDs := make(map[string]bool)
|
|
|
|
for i, task := range tasks {
|
|
// Check unique ID
|
|
if seenIDs[task.ID] {
|
|
return fmt.Errorf("task %d: duplicate task ID '%s'", i, task.ID)
|
|
}
|
|
seenIDs[task.ID] = true
|
|
|
|
// Check executor
|
|
if task.ExecutorID == "" {
|
|
return fmt.Errorf("task %d (%s): missing executor_id", i, task.ID)
|
|
}
|
|
|
|
// Check messages or description
|
|
if len(task.Messages) == 0 {
|
|
return fmt.Errorf("task %d (%s): missing messages or description", i, task.ID)
|
|
}
|
|
|
|
// Note: Executor existence is NOT validated here
|
|
// - ValidateExecutorExists() can be called separately if needed
|
|
// - Unknown executors will fail at P3 runtime with clear error message
|
|
// - This allows flexibility for dynamically registered executors
|
|
|
|
// Note: Validation rules are optional
|
|
// - P3 can still do basic validation without explicit rules
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateTasksWithResources validates tasks and checks executor existence
|
|
// Returns a list of warnings for unknown executors (does not fail)
|
|
func ValidateTasksWithResources(tasks []robottypes.Task, robot *robottypes.Robot) (warnings []string, err error) {
|
|
// First do basic validation
|
|
if err := ValidateTasks(tasks); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Then check executor existence (warnings only)
|
|
for _, task := range tasks {
|
|
if !ValidateExecutorExists(task.ExecutorID, task.ExecutorType, robot) {
|
|
warnings = append(warnings, fmt.Sprintf(
|
|
"task %s: executor '%s' (%s) not found in available resources",
|
|
task.ID, task.ExecutorID, task.ExecutorType,
|
|
))
|
|
}
|
|
}
|
|
|
|
return warnings, nil
|
|
}
|
|
|
|
// IsValidExecutorType checks if the executor type is valid
|
|
func IsValidExecutorType(t robottypes.ExecutorType) bool {
|
|
switch t {
|
|
case robottypes.ExecutorAssistant, robottypes.ExecutorMCP, robottypes.ExecutorProcess:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// SortTasksByOrder sorts tasks by their Order field (ascending)
|
|
// This ensures tasks are executed in the correct sequence regardless of
|
|
// the order they appear in the LLM response
|
|
func SortTasksByOrder(tasks []robottypes.Task) {
|
|
for i := 0; i < len(tasks)-1; i++ {
|
|
for j := i + 1; j < len(tasks); j++ {
|
|
if tasks[j].Order < tasks[i].Order {
|
|
tasks[i], tasks[j] = tasks[j], tasks[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ValidateExecutorExists checks if the executor ID exists in available resources
|
|
// This is an optional validation - tasks with unknown executors will still be created
|
|
// but may fail during P3 execution
|
|
// For MCP tasks, pass mcpServer as the second parameter (executorID is ignored for MCP)
|
|
func ValidateExecutorExists(executorID string, executorType robottypes.ExecutorType, robot *robottypes.Robot) bool {
|
|
if robot == nil || robot.Config == nil || robot.Config.Resources == nil {
|
|
return true // Skip validation if no resources configured
|
|
}
|
|
|
|
switch executorType {
|
|
case robottypes.ExecutorAssistant:
|
|
for _, agent := range robot.Config.Resources.Agents {
|
|
if agent == executorID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
|
|
case robottypes.ExecutorMCP:
|
|
// For MCP, executorID can be either:
|
|
// 1. The mcp_server value (new format)
|
|
// 2. The combined mcp_server.mcp_tool format (for display)
|
|
// We validate against mcp_server (the MCP server/client ID)
|
|
for _, mcp := range robot.Config.Resources.MCP {
|
|
if mcp.ID == executorID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
|
|
case robottypes.ExecutorProcess:
|
|
// Process executors are not validated against resources
|
|
// They are validated at runtime by the Yao process system
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ValidateMCPTask validates MCP task fields
|
|
// Returns an error if mcp_server or mcp_tool is missing for MCP tasks
|
|
func ValidateMCPTask(task *robottypes.Task) error {
|
|
if task.ExecutorType != robottypes.ExecutorMCP {
|
|
return nil
|
|
}
|
|
if task.MCPServer == "" {
|
|
return fmt.Errorf("MCP task %s: mcp_server field is required", task.ID)
|
|
}
|
|
if task.MCPTool == "" {
|
|
return fmt.Errorf("MCP task %s: mcp_tool field is required", task.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NormalizeTaskExecutors fixes LLM-generated executor_id and executor_type
|
|
// against the robot's actual resource lists. It handles two common LLM errors:
|
|
// 1. Partial executor_id (e.g. "report-writer" instead of "yao.report-writer")
|
|
// 2. Wrong executor_type (e.g. classifying an assistant as "mcp")
|
|
func NormalizeTaskExecutors(tasks []robottypes.Task, robot *robottypes.Robot) {
|
|
if robot == nil || robot.Config == nil || robot.Config.Resources == nil {
|
|
return
|
|
}
|
|
|
|
agentSet := make(map[string]bool, len(robot.Config.Resources.Agents))
|
|
for _, id := range robot.Config.Resources.Agents {
|
|
agentSet[id] = true
|
|
}
|
|
|
|
mcpSet := make(map[string]bool, len(robot.Config.Resources.MCP))
|
|
for _, m := range robot.Config.Resources.MCP {
|
|
mcpSet[m.ID] = true
|
|
}
|
|
|
|
for i := range tasks {
|
|
task := &tasks[i]
|
|
origID := task.ExecutorID
|
|
origType := task.ExecutorType
|
|
|
|
// Skip process tasks — they are not in resource lists
|
|
if task.ExecutorType == robottypes.ExecutorProcess {
|
|
continue
|
|
}
|
|
|
|
// Step 1: Try exact match first
|
|
if agentSet[task.ExecutorID] {
|
|
task.ExecutorType = robottypes.ExecutorAssistant
|
|
if origType != task.ExecutorType {
|
|
kunlog.Trace("[normalize] task %s: executor_type %s -> %s (crosscheck)", task.ID, origType, task.ExecutorType)
|
|
}
|
|
continue
|
|
}
|
|
if mcpSet[task.ExecutorID] {
|
|
task.ExecutorType = robottypes.ExecutorMCP
|
|
if origType != task.ExecutorType {
|
|
kunlog.Trace("[normalize] task %s: executor_type %s -> %s (crosscheck)", task.ID, origType, task.ExecutorType)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Step 2: Suffix match — LLM may omit namespace prefix
|
|
if match := suffixMatch(task.ExecutorID, robot.Config.Resources.Agents); match != "" {
|
|
task.ExecutorID = match
|
|
task.ExecutorType = robottypes.ExecutorAssistant
|
|
kunlog.Trace("[normalize] task %s: executor_id %s -> %s (suffix match)", task.ID, origID, match)
|
|
continue
|
|
}
|
|
|
|
mcpIDs := make([]string, 0, len(robot.Config.Resources.MCP))
|
|
for _, m := range robot.Config.Resources.MCP {
|
|
mcpIDs = append(mcpIDs, m.ID)
|
|
}
|
|
if match := suffixMatch(task.ExecutorID, mcpIDs); match != "" {
|
|
task.ExecutorID = match
|
|
task.ExecutorType = robottypes.ExecutorMCP
|
|
kunlog.Trace("[normalize] task %s: executor_id %s -> %s (suffix match)", task.ID, origID, match)
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// suffixMatch finds the first entry in candidates that ends with "."+partial
|
|
// (or equals partial exactly, which is already handled by the caller).
|
|
func suffixMatch(partial string, candidates []string) string {
|
|
suffix := "." + partial
|
|
for _, c := range candidates {
|
|
if strings.HasSuffix(c, suffix) {
|
|
return c
|
|
}
|
|
}
|
|
return ""
|
|
}
|