yao/agent/robot/executor/standard/validator.go
Max bc4787f857 Update executor to support V2 execution model and enhance event handling
- Implement V2 execution model in the standard executor, simplifying task execution to a single call without validation loops.
- Introduce support for resuming suspended executions, allowing for human input during task processing.
- Enhance event handling by pushing task completion and failure events to the event bus for better tracking and integration.
- Update tests to reflect changes in execution flow and ensure robust handling of task statuses and results.
2026-02-25 18:40:48 +08:00

715 lines
21 KiB
Go

package standard
import (
"encoding/json"
"fmt"
"strings"
"github.com/yaoapp/gou/process"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/assert"
)
// ValidatorConfig configures validation behavior (decoupled from RunConfig)
type ValidatorConfig struct {
// ValidationThreshold is the minimum score to pass validation (default: 0.6)
ValidationThreshold float64
}
// DefaultValidatorConfig returns the default validator configuration
func DefaultValidatorConfig() *ValidatorConfig {
return &ValidatorConfig{
ValidationThreshold: 0.6,
}
}
// Validator handles task result validation using a two-layer approach:
// 1. Rule-based validation: Uses yao/assert for deterministic rules (type, contains, regex, json_path)
// 2. Semantic validation: Calls Validation Agent for semantic understanding (ExpectedOutput)
type Validator struct {
ctx *robottypes.Context
robot *robottypes.Robot
config *ValidatorConfig
asserter *assert.Asserter
}
// NewValidator creates a new task validator
func NewValidator(ctx *robottypes.Context, robot *robottypes.Robot, config *ValidatorConfig) *Validator {
if config == nil {
config = DefaultValidatorConfig()
}
v := &Validator{
ctx: ctx,
robot: robot,
config: config,
asserter: assert.New(),
}
// Configure asserter with robot-specific implementations
v.asserter.WithAgentValidator(&robotAgentValidator{v: v})
v.asserter.WithScriptRunner(&robotScriptRunner{ctx: ctx})
return v
}
// Validate validates task output using two-layer validation (without multi-turn context)
// Equivalent to ValidateWithContext(task, output, nil)
// Use ValidateWithContext when you have a CallResult for better multi-turn support
func (v *Validator) Validate(task *robottypes.Task, output interface{}) *robottypes.ValidationResult {
return v.ValidateWithContext(task, output, nil)
}
// ValidateWithContext validates task output and determines execution state for multi-turn conversation.
// It extends basic validation with:
// - Complete: whether expected result is obtained
// - NeedReply: whether to continue conversation
// - ReplyContent: content for next turn
//
// Parameters:
// - task: the task being executed
// - output: the output from assistant/mcp/process
// - callResult: the full call result (for detecting assistant's need for more info)
func (v *Validator) ValidateWithContext(task *robottypes.Task, output interface{}, callResult *CallResult) *robottypes.ValidationResult {
// If no validation rules and no expected output, return passed and complete
if task.ExpectedOutput == "" && len(task.ValidationRules) == 0 {
return &robottypes.ValidationResult{
Passed: true,
Score: 1.0,
Complete: v.hasValidOutput(output),
}
}
result := &robottypes.ValidationResult{
Passed: true,
Score: 1.0,
}
// Layer 1: Rule-based validation (using yao/assert)
if len(task.ValidationRules) > 0 {
ruleResult := v.validateRules(task.ValidationRules, output)
if !ruleResult.Passed {
// Rule validation failed - check if we should retry with feedback
ruleResult.Complete = false
ruleResult.NeedReply, ruleResult.ReplyContent = v.checkNeedReplyOnFailure(task, ruleResult)
return ruleResult
}
// Merge rule validation results
result.Issues = append(result.Issues, ruleResult.Issues...)
result.Suggestions = append(result.Suggestions, ruleResult.Suggestions...)
}
// Layer 2: Semantic validation (using Validation Agent)
// Only run if ExpectedOutput is set or there are agent-type rules
if task.ExpectedOutput != "" || v.hasAgentRules(task.ValidationRules) {
semanticResult := v.validateSemantic(task, output)
result = v.mergeResults(result, semanticResult)
}
// Determine execution state
result.Complete = v.isComplete(task, output, result)
result.NeedReply, result.ReplyContent = v.checkNeedReply(task, output, callResult, result)
return result
}
// hasValidOutput checks if output is non-empty and valid
func (v *Validator) hasValidOutput(output interface{}) bool {
if output == nil {
return false
}
switch o := output.(type) {
case string:
return strings.TrimSpace(o) != ""
case []interface{}:
return len(o) > 0
case map[string]interface{}:
return len(o) > 0
default:
return true
}
}
// isComplete determines if the expected result has been obtained
func (v *Validator) isComplete(task *robottypes.Task, output interface{}, result *robottypes.ValidationResult) bool {
// If validation failed, not complete
if !result.Passed {
return false
}
// Must have valid output
if !v.hasValidOutput(output) {
return false
}
// If score is below threshold, consider incomplete
if result.Score < v.config.ValidationThreshold {
return false
}
return true
}
// checkNeedReply determines if conversation should continue and generates reply content
func (v *Validator) checkNeedReply(task *robottypes.Task, output interface{}, callResult *CallResult, result *robottypes.ValidationResult) (bool, string) {
// If already complete, no need to reply
if result.Complete {
return false, ""
}
// Scenario 1: Assistant explicitly asks for more information
if callResult != nil {
text := callResult.GetText()
if v.detectNeedMoreInfo(text) {
return true, v.generateClarificationReply(task, text)
}
}
// Scenario 2: Validation passed but output is incomplete/empty
if result.Passed && !v.hasValidOutput(output) {
return true, "Please continue and provide the complete result as specified in the task."
}
// Scenario 3: Validation failed with suggestions - can retry with feedback
if !result.Passed && len(result.Suggestions) > 0 {
return true, v.generateFeedbackReply(result)
}
// Scenario 4: Low confidence score - ask for improvement
if result.Passed && result.Score < v.config.ValidationThreshold {
return true, fmt.Sprintf("The result is partially correct (score: %.2f), but needs improvement. Please refine your response to better match the expected output: %s", result.Score, task.ExpectedOutput)
}
// No need to continue
return false, ""
}
// checkNeedReplyOnFailure handles the case when rule validation fails
func (v *Validator) checkNeedReplyOnFailure(task *robottypes.Task, result *robottypes.ValidationResult) (bool, string) {
// If there are suggestions, we can try to fix
if len(result.Suggestions) > 0 {
return true, v.generateFeedbackReply(result)
}
// If there are issues, provide feedback
if len(result.Issues) > 0 {
var sb strings.Builder
sb.WriteString("Your response did not pass validation. Please fix the following issues:\n\n")
for _, issue := range result.Issues {
sb.WriteString(fmt.Sprintf("- %s\n", issue))
}
sb.WriteString(fmt.Sprintf("\nExpected output: %s", task.ExpectedOutput))
return true, sb.String()
}
return false, ""
}
// detectNeedMoreInfo checks if assistant's response indicates need for more information
func (v *Validator) detectNeedMoreInfo(text string) bool {
if text == "" {
return false
}
textLower := strings.ToLower(text)
keywords := []string{
"need more information",
"please clarify",
"could you provide",
"can you specify",
"what is the",
"which one",
"please provide",
"i need to know",
"could you tell me",
"what do you mean",
}
for _, kw := range keywords {
if strings.Contains(textLower, kw) {
return true
}
}
// Check for question marks at the end (likely asking for clarification)
// Note: We require 2+ question marks to avoid false positives from rhetorical questions
// or questions that are part of the output (e.g., "How can I help you?")
// Single questions are often just conversational and don't need clarification
trimmed := strings.TrimSpace(text)
if strings.HasSuffix(trimmed, "?") {
if strings.Count(text, "?") >= 2 {
return true
}
}
return false
}
// generateClarificationReply generates a reply when assistant asks for clarification
func (v *Validator) generateClarificationReply(task *robottypes.Task, assistantText string) string {
var sb strings.Builder
sb.WriteString("Please proceed with the task based on the available information.\n\n")
if task.ExpectedOutput != "" {
sb.WriteString(fmt.Sprintf("**Expected Output**: %s\n\n", task.ExpectedOutput))
}
sb.WriteString("If you need to make assumptions, please state them clearly and proceed with the most reasonable interpretation.")
return sb.String()
}
// generateFeedbackReply generates a reply with validation feedback
func (v *Validator) generateFeedbackReply(result *robottypes.ValidationResult) string {
var sb strings.Builder
sb.WriteString("## Validation Feedback\n\n")
sb.WriteString("Your previous response needs improvement. Please address the following:\n\n")
if len(result.Issues) > 0 {
sb.WriteString("### Issues\n")
for _, issue := range result.Issues {
sb.WriteString(fmt.Sprintf("- %s\n", issue))
}
sb.WriteString("\n")
}
if len(result.Suggestions) > 0 {
sb.WriteString("### Suggestions\n")
for _, suggestion := range result.Suggestions {
sb.WriteString(fmt.Sprintf("- %s\n", suggestion))
}
sb.WriteString("\n")
}
sb.WriteString("Please provide an improved response that addresses these points.")
return sb.String()
}
// validateRules validates output against rule-based assertions
func (v *Validator) validateRules(rules []string, output interface{}) *robottypes.ValidationResult {
result := &robottypes.ValidationResult{
Passed: true,
Score: 1.0,
}
// Parse rules into assertions
assertions := v.parseRules(rules)
if len(assertions) == 0 {
return result
}
// Run assertions
passed, message := v.asserter.Validate(assertions, output)
if !passed {
result.Passed = false
result.Score = 0
result.Issues = append(result.Issues, message)
}
return result
}
// parseRules converts validation rules (strings or JSON) to assertions
// Supports:
// - Simple string rules: "output must be valid JSON" (converted to type check)
// - JSON assertion objects: {"type": "contains", "value": "success"}
func (v *Validator) parseRules(rules []string) []*assert.Assertion {
var assertions []*assert.Assertion
for _, rule := range rules {
// Try to parse as JSON assertion
if strings.HasPrefix(rule, "{") {
var assertionMap map[string]interface{}
if err := json.Unmarshal([]byte(rule), &assertionMap); err == nil {
parsed := assert.ParseAssertions(assertionMap)
assertions = append(assertions, parsed...)
continue
}
}
// Convert common string rules to assertions
assertion := v.convertStringRule(rule)
if assertion != nil {
assertions = append(assertions, assertion)
}
}
return assertions
}
// convertStringRule converts a human-readable rule string to an assertion
// Examples:
// - "output must be valid JSON" -> {"type": "type", "value": "object"}
// - "must contain 'success'" -> {"type": "contains", "value": "success"}
// - "count > 0" -> (passed to semantic validation)
func (v *Validator) convertStringRule(rule string) *assert.Assertion {
ruleLower := strings.ToLower(rule)
// JSON type check
if strings.Contains(ruleLower, "valid json") || strings.Contains(ruleLower, "json object") {
return &assert.Assertion{
Type: "type",
Value: "object",
Message: rule,
}
}
// Array type check
if strings.Contains(ruleLower, "json array") || strings.Contains(ruleLower, "must be array") {
return &assert.Assertion{
Type: "type",
Value: "array",
Message: rule,
}
}
// Contains check
if strings.Contains(ruleLower, "contain") {
// Extract the value in quotes
if start := strings.Index(rule, "'"); start != -1 {
if end := strings.Index(rule[start+1:], "'"); end != -1 {
value := rule[start+1 : start+1+end]
return &assert.Assertion{
Type: "contains",
Value: value,
Message: rule,
}
}
}
if start := strings.Index(rule, "\""); start != -1 {
if end := strings.Index(rule[start+1:], "\""); end != -1 {
value := rule[start+1 : start+1+end]
return &assert.Assertion{
Type: "contains",
Value: value,
Message: rule,
}
}
}
}
// Not empty check - use regex to match at least one character
if strings.Contains(ruleLower, "not empty") || strings.Contains(ruleLower, "non-empty") {
return &assert.Assertion{
Type: "regex",
Value: ".+",
Message: rule,
}
}
// For other rules, return nil (will be handled by semantic validation)
return nil
}
// hasAgentRules checks if any rule requires agent-based validation
func (v *Validator) hasAgentRules(rules []string) bool {
for _, rule := range rules {
if strings.HasPrefix(rule, "{") {
var assertionMap map[string]interface{}
if err := json.Unmarshal([]byte(rule), &assertionMap); err == nil {
if assertionMap["type"] == "agent" {
return true
}
}
}
}
return false
}
// validateSemantic performs semantic validation using the Validation Agent
func (v *Validator) validateSemantic(task *robottypes.Task, output interface{}) *robottypes.ValidationResult {
// Get validation agent ID
validationAgentID := "__yao.validation" // default
if v.robot.Config != nil && v.robot.Config.Resources != nil {
if customID, ok := v.robot.Config.Resources.Phases["validation"]; ok && customID != "" {
validationAgentID = customID
}
}
// Build validation prompt
validationPrompt := v.BuildSemanticPrompt(task, output)
// Call validation agent
caller := NewAgentCaller()
result, err := caller.CallWithMessages(v.ctx, validationAgentID, validationPrompt)
if err != nil {
return &robottypes.ValidationResult{
Passed: false,
Score: 0,
Issues: []string{fmt.Sprintf("Validation agent error: %s", err.Error())},
}
}
return v.ParseAgentResult(result)
}
// BuildSemanticPrompt builds the prompt for semantic validation
// Format matches the Validation Agent's expected input structure:
// 1. Task: task definition with expected_output and validation_rules
// 2. Result: actual output from task execution
// 3. Success Criteria: overall criteria (optional)
func (v *Validator) BuildSemanticPrompt(task *robottypes.Task, output interface{}) string {
var sb strings.Builder
// Section 1: Task (matches Agent's expected "Task" input)
sb.WriteString("## Task\n\n")
sb.WriteString(fmt.Sprintf("**Task ID**: %s\n", task.ID))
sb.WriteString(fmt.Sprintf("**Executor**: %s (%s)\n\n", task.ExecutorID, task.ExecutorType))
// Task description (instructions)
if len(task.Messages) > 0 {
sb.WriteString("**Instructions**:\n")
for _, msg := range task.Messages {
if content, ok := msg.Content.(string); ok {
sb.WriteString(content + "\n")
}
}
sb.WriteString("\n")
}
// Expected output (primary criterion for semantic validation)
if task.ExpectedOutput != "" {
sb.WriteString(fmt.Sprintf("**expected_output**: %s\n\n", task.ExpectedOutput))
}
// Validation rules
semanticRules := v.getSemanticRules(task.ValidationRules)
if len(semanticRules) > 0 {
sb.WriteString("**validation_rules**:\n")
for _, rule := range semanticRules {
sb.WriteString(fmt.Sprintf("- %s\n", rule))
}
sb.WriteString("\n")
}
// Section 2: Result (matches Agent's expected "Result" input)
sb.WriteString("## Result\n\n")
if output != nil {
outputJSON, err := json.MarshalIndent(output, "", " ")
if err == nil {
sb.WriteString(fmt.Sprintf("```json\n%s\n```\n", string(outputJSON)))
} else {
sb.WriteString(fmt.Sprintf("%v\n", output))
}
} else {
sb.WriteString("(no output)\n")
}
// Section 3: Success Criteria (optional, from goals if available)
// Note: This could be extended to include criteria from exec.Goals if needed
sb.WriteString("\n## Success Criteria\n\n")
if task.ExpectedOutput != "" {
sb.WriteString(fmt.Sprintf("The task should produce: %s\n", task.ExpectedOutput))
} else {
sb.WriteString("Complete the task successfully with valid output.\n")
}
return sb.String()
}
// getSemanticRules returns rules that need semantic validation (not convertible to assertions)
func (v *Validator) getSemanticRules(rules []string) []string {
var semanticRules []string
for _, rule := range rules {
// Skip JSON assertions (already handled)
if strings.HasPrefix(rule, "{") {
continue
}
// Skip rules that were converted to assertions
if v.convertStringRule(rule) == nil {
semanticRules = append(semanticRules, rule)
}
}
return semanticRules
}
// ParseAgentResult parses the validation agent's response
func (v *Validator) ParseAgentResult(result *CallResult) *robottypes.ValidationResult {
validation := &robottypes.ValidationResult{
Passed: false,
Score: 0,
}
// Try to parse as JSON
data, err := result.GetJSON()
if err != nil {
// If not JSON, try to interpret the text response
text := result.GetText()
if text != "" {
validation.Details = text
// Simple heuristic: check for positive keywords
textLower := strings.ToLower(text)
positiveKeywords := []string{"passed", "valid", "correct", "success"}
for _, keyword := range positiveKeywords {
if strings.Contains(textLower, keyword) {
validation.Passed = true
validation.Score = 0.8
break
}
}
}
return validation
}
// Parse JSON fields
if passed, ok := data["passed"].(bool); ok {
validation.Passed = passed
}
if score, ok := data["score"].(float64); ok {
validation.Score = score
}
if issues, ok := data["issues"].([]interface{}); ok {
for _, issue := range issues {
if s, ok := issue.(string); ok {
validation.Issues = append(validation.Issues, s)
}
}
}
if suggestions, ok := data["suggestions"].([]interface{}); ok {
for _, suggestion := range suggestions {
if s, ok := suggestion.(string); ok {
validation.Suggestions = append(validation.Suggestions, s)
}
}
}
if details, ok := data["details"].(string); ok {
validation.Details = details
}
return validation
}
// mergeResults merges rule-based and semantic validation results
func (v *Validator) mergeResults(ruleResult, semanticResult *robottypes.ValidationResult) *robottypes.ValidationResult {
// If either failed, the overall result is failed
if !ruleResult.Passed || !semanticResult.Passed {
return &robottypes.ValidationResult{
Passed: false,
Score: min(ruleResult.Score, semanticResult.Score),
Issues: append(ruleResult.Issues, semanticResult.Issues...),
Suggestions: append(ruleResult.Suggestions, semanticResult.Suggestions...),
Details: semanticResult.Details,
}
}
// Both passed
return &robottypes.ValidationResult{
Passed: true,
Score: (ruleResult.Score + semanticResult.Score) / 2,
Issues: append(ruleResult.Issues, semanticResult.Issues...),
Suggestions: append(ruleResult.Suggestions, semanticResult.Suggestions...),
Details: semanticResult.Details,
}
}
// ============================================================================
// Robot-specific implementations of assert interfaces
// ============================================================================
// robotAgentValidator implements assert.AgentValidator for robot package
type robotAgentValidator struct {
v *Validator
}
// Validate validates output using an agent
func (av *robotAgentValidator) Validate(agentID string, output, input, criteria interface{}, options *assert.AssertionOptions) *assert.Result {
result := &assert.Result{}
// Build validation request
validationInput := map[string]interface{}{
"output": output,
"input": input,
}
if criteria != nil {
validationInput["criteria"] = criteria
}
inputJSON, err := json.Marshal(validationInput)
if err != nil {
result.Passed = false
result.Message = fmt.Sprintf("failed to marshal validation input: %s", err.Error())
return result
}
// Call agent
caller := NewAgentCaller()
callResult, err := caller.CallWithMessages(av.v.ctx, agentID, string(inputJSON))
if err != nil {
result.Passed = false
result.Message = fmt.Sprintf("agent validation error: %s", err.Error())
return result
}
// Parse response
data, err := callResult.GetJSON()
if err != nil {
result.Passed = false
result.Message = "agent returned invalid response format"
return result
}
if passed, ok := data["passed"].(bool); ok {
result.Passed = passed
}
if reason, ok := data["reason"].(string); ok {
result.Message = reason
}
result.Expected = data
return result
}
// robotScriptRunner implements assert.ScriptRunner for robot package
type robotScriptRunner struct {
ctx *robottypes.Context
}
// Run runs an assertion script using Yao process
func (r *robotScriptRunner) Run(scriptName string, output, input, expected interface{}) (bool, string, error) {
// Build script arguments
args := []interface{}{output, input, expected}
// Create and run the process
proc, err := process.Of(scriptName, args...)
if err != nil {
return false, "", fmt.Errorf("failed to create process: %w", err)
}
// Set context for timeout and cancellation support
if r.ctx != nil {
proc.Context = r.ctx.Context
}
if err := proc.Execute(); err != nil {
return false, "", fmt.Errorf("script execution failed: %w", err)
}
defer proc.Release()
// Parse result - expected format: bool or { "pass": bool, "message": string }
res := proc.Value()
switch v := res.(type) {
case bool:
if v {
return true, "script assertion passed", nil
}
return false, "script assertion failed", nil
case map[string]interface{}:
passed := false
message := ""
if pass, ok := v["pass"].(bool); ok {
passed = pass
}
if msg, ok := v["message"].(string); ok {
message = msg
}
return passed, message, nil
default:
return false, fmt.Sprintf("script returned unexpected type: %T", res), nil
}
}