- Updated the `RunConfig` to include parameters for multi-turn conversation control, such as `ContinueOnFailure`, `ValidationThreshold`, and `MaxTurnsPerTask`. - Implemented a new multi-turn conversation flow for assistant tasks, allowing for iterative interactions until completion or maximum turns are reached. - Enhanced the `ValidationResult` structure to support multi-turn states, including fields for `Complete`, `NeedReply`, and `ReplyContent`. - Refined the `ExecuteWithRetry` method to accommodate the new conversation flow, ensuring proper handling of task execution and validation. - Revised the `Validator` to include logic for determining when to continue conversations based on validation results. - Updated documentation and tests to reflect the new multi-turn capabilities and validation mechanisms, ensuring comprehensive coverage of the changes.
394 lines
12 KiB
Go
394 lines
12 KiB
Go
package standard
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yaoapp/gou/mcp"
|
|
"github.com/yaoapp/gou/process"
|
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
|
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
|
)
|
|
|
|
// Runner handles execution of individual tasks
|
|
type Runner struct {
|
|
ctx *robottypes.Context
|
|
robot *robottypes.Robot
|
|
config *RunConfig
|
|
validator *Validator // reusable validator instance
|
|
}
|
|
|
|
// NewRunner creates a new task runner
|
|
func NewRunner(ctx *robottypes.Context, robot *robottypes.Robot, config *RunConfig) *Runner {
|
|
return &Runner{
|
|
ctx: ctx,
|
|
robot: robot,
|
|
config: config,
|
|
validator: NewValidator(ctx, robot, config),
|
|
}
|
|
}
|
|
|
|
// RunnerContext provides context for task execution
|
|
type RunnerContext struct {
|
|
// PreviousResults contains results from previously executed tasks
|
|
PreviousResults []robottypes.TaskResult
|
|
|
|
// Goals contains the goals from P1 (for context)
|
|
Goals *robottypes.Goals
|
|
|
|
// SystemPrompt is the robot's system prompt
|
|
SystemPrompt string
|
|
}
|
|
|
|
// BuildTaskContext builds context for a task including previous results
|
|
func (r *Runner) BuildTaskContext(exec *robottypes.Execution, taskIndex int) *RunnerContext {
|
|
ctx := &RunnerContext{
|
|
Goals: exec.Goals,
|
|
SystemPrompt: r.robot.SystemPrompt,
|
|
}
|
|
|
|
// Include results from previous tasks (with bounds check)
|
|
if taskIndex > 0 && len(exec.Results) > 0 {
|
|
endIndex := taskIndex
|
|
if endIndex > len(exec.Results) {
|
|
endIndex = len(exec.Results)
|
|
}
|
|
ctx.PreviousResults = exec.Results[:endIndex]
|
|
}
|
|
|
|
return ctx
|
|
}
|
|
|
|
// ExecuteWithRetry executes a task with the new multi-turn conversation flow:
|
|
// 1. Call assistant and get result
|
|
// 2. Validate result (determines: passed, complete, needReply, replyContent)
|
|
// 3. If needReply, continue conversation with replyContent
|
|
// 4. Repeat until complete or max turns exceeded
|
|
func (r *Runner) ExecuteWithRetry(task *robottypes.Task, taskCtx *RunnerContext) *robottypes.TaskResult {
|
|
startTime := time.Now()
|
|
|
|
result := &robottypes.TaskResult{
|
|
TaskID: task.ID,
|
|
}
|
|
|
|
// For non-assistant tasks (MCP, Process), use simple single-call execution
|
|
if task.ExecutorType != robottypes.ExecutorAssistant {
|
|
output, err := r.executeNonAssistantTask(task, taskCtx)
|
|
if err != nil {
|
|
result.Success = false
|
|
result.Error = fmt.Sprintf("execution failed: %s", err.Error())
|
|
result.Duration = time.Since(startTime).Milliseconds()
|
|
return result
|
|
}
|
|
|
|
result.Output = output
|
|
validation := r.validator.ValidateWithContext(task, output, nil)
|
|
result.Validation = validation
|
|
// For non-assistant tasks (MCP, Process):
|
|
// - No multi-turn conversation, so Complete is determined by validation alone
|
|
// - Success if passed OR score meets threshold (for partial success scenarios)
|
|
result.Success = validation.Complete || (validation.Passed && validation.Score >= r.config.ValidationThreshold)
|
|
result.Duration = time.Since(startTime).Milliseconds()
|
|
|
|
if !result.Success && validation != nil {
|
|
result.Error = fmt.Sprintf("validation failed: %v", validation.Issues)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// For assistant tasks, use multi-turn conversation flow
|
|
output, validation, err := r.executeAssistantWithMultiTurn(task, taskCtx)
|
|
if err != nil {
|
|
result.Success = false
|
|
result.Error = err.Error()
|
|
result.Output = output // Preserve partial output for debugging
|
|
result.Validation = validation // Preserve validation result for debugging
|
|
result.Duration = time.Since(startTime).Milliseconds()
|
|
return result
|
|
}
|
|
|
|
result.Output = output
|
|
result.Validation = validation
|
|
result.Success = validation.Complete && validation.Passed
|
|
result.Duration = time.Since(startTime).Milliseconds()
|
|
|
|
if !result.Success && validation != nil {
|
|
result.Error = fmt.Sprintf("task incomplete: %v", validation.Issues)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// executeNonAssistantTask executes MCP or Process tasks (single-call, no multi-turn)
|
|
func (r *Runner) executeNonAssistantTask(task *robottypes.Task, taskCtx *RunnerContext) (interface{}, error) {
|
|
switch task.ExecutorType {
|
|
case robottypes.ExecutorMCP:
|
|
return r.ExecuteMCPTask(task, taskCtx)
|
|
case robottypes.ExecutorProcess:
|
|
return r.ExecuteProcessTask(task, taskCtx)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported executor type: %s (expected mcp or process)", task.ExecutorType)
|
|
}
|
|
}
|
|
|
|
// executeAssistantWithMultiTurn executes an assistant task with multi-turn conversation support
|
|
// This is the main execution flow for assistant tasks:
|
|
// 1. Call assistant and get result
|
|
// 2. Validate result (determines: passed, complete, needReply, replyContent)
|
|
// 3. If needReply, continue conversation with replyContent
|
|
// 4. Repeat until complete or max turns exceeded
|
|
func (r *Runner) executeAssistantWithMultiTurn(task *robottypes.Task, taskCtx *RunnerContext) (interface{}, *robottypes.ValidationResult, error) {
|
|
// Create conversation for the entire task execution (shared across all turns)
|
|
chatID := fmt.Sprintf("robot-%s-task-%s", r.robot.MemberID, task.ID)
|
|
conv := NewConversation(task.ExecutorID, chatID, r.config.MaxTurnsPerTask)
|
|
|
|
// Add system prompt if available
|
|
if taskCtx.SystemPrompt != "" {
|
|
conv.WithSystemPrompt(taskCtx.SystemPrompt)
|
|
}
|
|
|
|
// Build initial messages
|
|
messages := r.BuildAssistantMessages(task, taskCtx)
|
|
input := r.FormatMessagesAsText(messages)
|
|
|
|
// Ensure we have valid input for the first turn
|
|
if strings.TrimSpace(input) == "" {
|
|
return nil, nil, fmt.Errorf("no valid input messages for task %s", task.ID)
|
|
}
|
|
|
|
var lastOutput interface{}
|
|
var lastValidation *robottypes.ValidationResult
|
|
var lastCallResult *CallResult
|
|
|
|
for turn := 1; turn <= r.config.MaxTurnsPerTask; turn++ {
|
|
// Phase 1: Call assistant
|
|
turnResult, err := conv.Turn(r.ctx, input)
|
|
if err != nil {
|
|
return lastOutput, lastValidation, fmt.Errorf("turn %d failed: %w", turn, err)
|
|
}
|
|
|
|
lastCallResult = turnResult.Result
|
|
lastOutput = r.extractOutput(lastCallResult)
|
|
|
|
// Phase 2: Validate result
|
|
lastValidation = r.validator.ValidateWithContext(task, lastOutput, lastCallResult)
|
|
|
|
// Check if complete
|
|
if lastValidation.Complete && lastValidation.Passed {
|
|
return lastOutput, lastValidation, nil // Success!
|
|
}
|
|
|
|
// Phase 3: Check if we should continue conversation
|
|
if !lastValidation.NeedReply {
|
|
// No need to continue, but not complete either
|
|
// This could be a validation failure that can't be fixed by conversation
|
|
if lastValidation.Passed {
|
|
// Passed but not complete (e.g., empty output)
|
|
return lastOutput, lastValidation, nil
|
|
}
|
|
// Failed and can't retry
|
|
return lastOutput, lastValidation, fmt.Errorf("validation failed: %v", lastValidation.Issues)
|
|
}
|
|
|
|
// Prepare next turn input
|
|
input = lastValidation.ReplyContent
|
|
if input == "" {
|
|
// Fallback: generate default reply
|
|
input = r.generateDefaultReply(lastValidation, task)
|
|
}
|
|
}
|
|
|
|
// Max turns exceeded
|
|
if lastValidation == nil {
|
|
lastValidation = &robottypes.ValidationResult{
|
|
Passed: false,
|
|
Complete: false,
|
|
Issues: []string{fmt.Sprintf("max turns (%d) exceeded without completion", r.config.MaxTurnsPerTask)},
|
|
}
|
|
} else {
|
|
lastValidation.Issues = append(lastValidation.Issues,
|
|
fmt.Sprintf("max turns (%d) exceeded without completion", r.config.MaxTurnsPerTask))
|
|
}
|
|
|
|
return lastOutput, lastValidation, fmt.Errorf("max turns (%d) exceeded without completion", r.config.MaxTurnsPerTask)
|
|
}
|
|
|
|
// extractOutput extracts the output from a CallResult
|
|
func (r *Runner) extractOutput(result *CallResult) interface{} {
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
|
|
// Try to extract structured JSON output
|
|
if data, err := result.GetJSON(); err == nil {
|
|
return data
|
|
}
|
|
|
|
// Fall back to text content
|
|
return result.GetText()
|
|
}
|
|
|
|
// generateDefaultReply generates a default reply when validation doesn't provide one
|
|
func (r *Runner) generateDefaultReply(validation *robottypes.ValidationResult, task *robottypes.Task) string {
|
|
var sb strings.Builder
|
|
|
|
if len(validation.Issues) > 0 {
|
|
sb.WriteString("Please address the following issues:\n")
|
|
for _, issue := range validation.Issues {
|
|
sb.WriteString(fmt.Sprintf("- %s\n", issue))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if task.ExpectedOutput != "" {
|
|
sb.WriteString(fmt.Sprintf("Expected output: %s\n", task.ExpectedOutput))
|
|
}
|
|
|
|
sb.WriteString("\nPlease provide an improved response.")
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// ExecuteMCPTask executes a task using an MCP tool
|
|
// ExecutorID format: "mcpClientID.toolName" (e.g., "filesystem.read_file")
|
|
func (r *Runner) ExecuteMCPTask(task *robottypes.Task, taskCtx *RunnerContext) (interface{}, error) {
|
|
// Parse MCP executor ID (format: clientID.toolName)
|
|
parts := strings.SplitN(task.ExecutorID, ".", 2)
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("invalid MCP executor ID: %s (expected format: clientID.toolName)", task.ExecutorID)
|
|
}
|
|
|
|
clientID, toolName := parts[0], parts[1]
|
|
|
|
// Get MCP client
|
|
client, err := mcp.Select(clientID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("MCP client not found: %s: %w", clientID, err)
|
|
}
|
|
|
|
// Build arguments map from task.Args
|
|
args := make(map[string]interface{})
|
|
if len(task.Args) > 0 {
|
|
// First argument should be a map of tool arguments
|
|
if argsMap, ok := task.Args[0].(map[string]interface{}); ok {
|
|
args = argsMap
|
|
} else {
|
|
// If not a map, try to convert single argument
|
|
args["input"] = task.Args[0]
|
|
}
|
|
}
|
|
|
|
// Call MCP tool
|
|
result, err := client.CallTool(r.ctx.Context, toolName, args)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("MCP tool call failed: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ExecuteProcessTask executes a task using a Yao process
|
|
// ExecutorID is the process name (e.g., "models.user.Find", "scripts.myScript.Run")
|
|
func (r *Runner) ExecuteProcessTask(task *robottypes.Task, taskCtx *RunnerContext) (interface{}, error) {
|
|
// Create process with task arguments
|
|
proc, err := process.Of(task.ExecutorID, task.Args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("process creation failed: %w", err)
|
|
}
|
|
|
|
// Set context for timeout and cancellation
|
|
proc.Context = r.ctx.Context
|
|
|
|
// Execute the process
|
|
if err := proc.Execute(); err != nil {
|
|
return nil, fmt.Errorf("process execution failed: %w", err)
|
|
}
|
|
defer proc.Release()
|
|
|
|
// Return the result
|
|
return proc.Value(), nil
|
|
}
|
|
|
|
// BuildAssistantMessages builds messages for an assistant task
|
|
// Note: In the new multi-turn flow, validation feedback is handled via ValidateWithContext.ReplyContent
|
|
func (r *Runner) BuildAssistantMessages(task *robottypes.Task, taskCtx *RunnerContext) []agentcontext.Message {
|
|
messages := make([]agentcontext.Message, 0)
|
|
|
|
// Add context from previous tasks if available
|
|
if len(taskCtx.PreviousResults) > 0 {
|
|
contextMsg := r.FormatPreviousResultsAsContext(taskCtx.PreviousResults)
|
|
if contextMsg != "" {
|
|
messages = append(messages, agentcontext.Message{
|
|
Role: agentcontext.RoleUser,
|
|
Content: contextMsg,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Add task messages
|
|
messages = append(messages, task.Messages...)
|
|
|
|
return messages
|
|
}
|
|
|
|
// FormatMessagesAsText converts messages to a single text string
|
|
func (r *Runner) FormatMessagesAsText(messages []agentcontext.Message) string {
|
|
var result string
|
|
for _, msg := range messages {
|
|
switch content := msg.Content.(type) {
|
|
case string:
|
|
result += content + "\n\n"
|
|
case []interface{}:
|
|
// Handle multi-part content (e.g., text + images)
|
|
for _, part := range content {
|
|
if textPart, ok := part.(map[string]interface{}); ok {
|
|
if text, ok := textPart["text"].(string); ok {
|
|
result += text + "\n\n"
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
// Try JSON marshaling as fallback
|
|
if content != nil {
|
|
if jsonBytes, err := json.Marshal(content); err == nil {
|
|
result += string(jsonBytes) + "\n\n"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// FormatPreviousResultsAsContext formats previous task results as context
|
|
func (r *Runner) FormatPreviousResultsAsContext(results []robottypes.TaskResult) string {
|
|
if len(results) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("## Previous Task Results\n\n")
|
|
sb.WriteString("The following tasks have been completed. Use their results as needed:\n\n")
|
|
|
|
for _, result := range results {
|
|
sb.WriteString(fmt.Sprintf("### Task: %s\n", result.TaskID))
|
|
if result.Success {
|
|
sb.WriteString("- Status: ✓ Success\n")
|
|
} else {
|
|
sb.WriteString("- Status: ✗ Failed\n")
|
|
}
|
|
|
|
if result.Output != nil {
|
|
outputJSON, err := json.MarshalIndent(result.Output, "", " ")
|
|
if err == nil {
|
|
sb.WriteString(fmt.Sprintf("- Output:\n```json\n%s\n```\n", string(outputJSON)))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("- Output: %v\n", result.Output))
|
|
}
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|