- Add `language_model` field to robot data structures for LLM connector overrides. - Update `AgentCaller` to utilize the robot's language model and include logging capabilities for agent calls. - Refactor task execution to log task outputs and inputs, improving observability during execution. - Modify tests to accommodate changes in the runner initialization and ensure proper logging functionality.
396 lines
11 KiB
Go
396 lines
11 KiB
Go
package standard
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
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
|
|
agentID := "__yao.tasks" // default
|
|
if robot.Config != nil && robot.Config.Resources != nil {
|
|
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseTasks)
|
|
}
|
|
|
|
// 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.Connector = robot.LanguageModel
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|