yao/agent/robot/executor/standard/tasks.go
Max f230f1e90c feat(workspace): enhance attachment handling and execution context
- 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.
2026-05-07 19:32:48 +08:00

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 ""
}