yao/agent/test/dynamic_runner.go
Max 5a63d9280b Implement Tool Call Assertions in Asserter for Enhanced Validation
- Added support for `tool_called` and `tool_result` assertions in the Asserter, allowing for validation of tool execution and arguments.
- Introduced methods to check if specific tools were called and to validate their results against expected patterns.
- Updated the `README.md` to include detailed documentation on new assertion types, including usage examples and value formats.
- Enhanced the dynamic runner to set the response for tool-related assertions, improving the overall testing framework's capabilities.
2025-12-27 14:23:15 +08:00

684 lines
18 KiB
Go

package test
import (
"fmt"
"time"
jsoniter "github.com/json-iterator/go"
goutext "github.com/yaoapp/gou/text"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
)
// DynamicRunner handles dynamic (simulator-driven) test execution
type DynamicRunner struct {
opts *Options
output *OutputWriter
asserter *Asserter
}
// NewDynamicRunner creates a new dynamic runner
func NewDynamicRunner(opts *Options) *DynamicRunner {
return &DynamicRunner{
opts: opts,
output: NewOutputWriter(opts.Verbose),
asserter: NewAsserter(),
}
}
// RunDynamic executes a dynamic test case
func (r *DynamicRunner) RunDynamic(ast *assistant.Assistant, tc *Case, agentID string) *DynamicResult {
startTime := time.Now()
result := &DynamicResult{
ID: tc.ID,
Turns: make([]*TurnResult, 0),
Checkpoints: make(map[string]*CheckpointResult),
}
// Initialize checkpoints
for _, cp := range tc.Checkpoints {
result.Checkpoints[cp.ID] = &CheckpointResult{
ID: cp.ID,
Reached: false,
Required: cp.IsRequired(),
}
}
// Get simulator agent
simAST, err := assistant.Get(tc.Simulator.Use)
if err != nil {
result.Status = StatusError
result.Error = fmt.Sprintf("failed to get simulator agent: %s", err.Error())
result.DurationMs = time.Since(startTime).Milliseconds()
return result
}
// Get configuration
maxTurns := tc.GetMaxTurns()
timeout := tc.GetTimeout(r.opts.Timeout)
// Build simulator metadata
simMetadata := make(map[string]interface{})
if tc.Simulator.Options != nil && tc.Simulator.Options.Metadata != nil {
for k, v := range tc.Simulator.Options.Metadata {
simMetadata[k] = v
}
}
// Conversation history
messages := make([]context.Message, 0)
// Get initial input if provided
initialMessages, err := tc.GetMessages()
if err == nil && len(initialMessages) > 0 {
messages = append(messages, initialMessages...)
}
// Output dynamic test start
if r.opts.Verbose {
r.output.Verbose("Dynamic test: %s (max %d turns)", tc.ID, maxTurns)
}
// Use consistent chatID across all turns to preserve session state (ctx.memory.chat)
// Priority: context config > generated ID
chatID := fmt.Sprintf("dynamic-%s", tc.ID)
if r.opts.ContextData != nil && r.opts.ContextData.ChatID != "" {
chatID = r.opts.ContextData.ChatID
}
// Conversation loop
for turn := 1; turn <= maxTurns; turn++ {
turnStart := time.Now()
turnResult := &TurnResult{Turn: turn}
// Check timeout
if time.Since(startTime) > timeout {
result.Status = StatusTimeout
result.Error = fmt.Sprintf("timeout after %s", timeout)
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = turn - 1
return result
}
// For turns after the first, get input from simulator
if turn > 1 || len(messages) == 0 {
simInput := r.buildSimulatorInput(tc, messages, result, turn, maxTurns, simMetadata)
simOutput, err := r.callSimulator(simAST, tc, simInput)
if err != nil {
turnResult.Error = fmt.Sprintf("simulator error: %s", err.Error())
result.Turns = append(result.Turns, turnResult)
result.Status = StatusError
result.Error = turnResult.Error
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = turn
return result
}
// Check if goal achieved
if simOutput.GoalAchieved {
if r.opts.Verbose {
r.output.Verbose("Turn %d: Simulator signaled goal achieved", turn)
}
// Check if all required checkpoints reached
if r.allRequiredCheckpointsReached(result) {
result.Status = StatusPassed
} else {
result.Status = StatusFailed
result.Error = "simulator signaled goal achieved but not all required checkpoints reached"
}
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = turn - 1
return result
}
// Add user message
userMessage := context.Message{
Role: context.RoleUser,
Content: simOutput.Message,
}
messages = append(messages, userMessage)
turnResult.Input = simOutput.Message
if r.opts.Verbose {
r.output.Verbose("Turn %d: User: %s", turn, truncateOutput(simOutput.Message, 50))
}
} else {
// Use initial input for first turn
if len(messages) > 0 {
lastMsg := messages[len(messages)-1]
turnResult.Input = lastMsg.Content
if r.opts.Verbose {
r.output.Verbose("Turn %d: User: %s", turn, truncateOutput(lastMsg.Content, 50))
}
}
}
// Call target agent
// Use consistent chatID across all turns to preserve session state (ctx.memory.chat)
ctx := NewTestContextFromOptions(
chatID,
agentID,
r.opts,
tc,
)
opts := buildContextOptions(tc, r.opts)
response, err := ast.Stream(ctx, messages, opts)
ctx.Release()
if err != nil {
turnResult.Error = err.Error()
turnResult.DurationMs = time.Since(turnStart).Milliseconds()
result.Turns = append(result.Turns, turnResult)
result.Status = StatusError
result.Error = fmt.Sprintf("agent error at turn %d: %s", turn, err.Error())
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = turn
return result
}
// Extract output (summary for display and conversation history)
output := extractOutput(response)
turnResult.Output = output
turnResult.DurationMs = time.Since(turnStart).Milliseconds()
// Store full response for reporting
turnResult.Response = buildTurnResponse(response)
if r.opts.Verbose {
r.output.Verbose("Turn %d: Agent: %s", turn, truncateOutput(output, 50))
}
// Add assistant response to messages, including tool calls if any
messages = appendAssistantMessages(messages, response)
// Check checkpoints against this response (including tool results)
reachedIDs := r.checkCheckpoints(tc.Checkpoints, response, result)
turnResult.CheckpointsReached = reachedIDs
if r.opts.Verbose && len(reachedIDs) > 0 {
for _, id := range reachedIDs {
r.output.Verbose(" ✓ checkpoint: %s", id)
}
}
result.Turns = append(result.Turns, turnResult)
// Check if all required checkpoints reached
if r.allRequiredCheckpointsReached(result) {
result.Status = StatusPassed
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = turn
return result
}
}
// Max turns exceeded
result.Status = StatusFailed
result.Error = fmt.Sprintf("max turns (%d) exceeded without reaching all checkpoints", maxTurns)
result.DurationMs = time.Since(startTime).Milliseconds()
result.TotalTurns = maxTurns
return result
}
// buildSimulatorInput builds the input for the simulator agent
func (r *DynamicRunner) buildSimulatorInput(
tc *Case,
messages []context.Message,
result *DynamicResult,
turn, maxTurns int,
metadata map[string]interface{},
) *SimulatorInput {
input := &SimulatorInput{
Conversation: messages,
TurnNumber: turn,
MaxTurns: maxTurns,
}
// Extract persona and goal from metadata
if persona, ok := metadata["persona"].(string); ok {
input.Persona = persona
}
if goal, ok := metadata["goal"].(string); ok {
input.Goal = goal
}
// Build checkpoint lists
input.CheckpointsReached = make([]string, 0)
input.CheckpointsPending = make([]string, 0)
for id, cp := range result.Checkpoints {
if cp.Reached {
input.CheckpointsReached = append(input.CheckpointsReached, id)
} else {
input.CheckpointsPending = append(input.CheckpointsPending, id)
}
}
// Store extra metadata
input.Extra = make(map[string]interface{})
for k, v := range metadata {
if k != "persona" && k != "goal" {
input.Extra[k] = v
}
}
return input
}
// callSimulator calls the simulator agent and parses the response
func (r *DynamicRunner) callSimulator(simAST *assistant.Assistant, tc *Case, input *SimulatorInput) (*SimulatorOutput, error) {
// Create context
env := NewEnvironment("", "")
ctx := NewTestContext("simulator", tc.Simulator.Use, env)
defer ctx.Release()
// Build options - skip history and trace
opts := &context.Options{
Skip: &context.Skip{
History: true,
Trace: true,
Output: true,
},
Metadata: map[string]interface{}{
"test_mode": "simulator",
},
}
// Override connector if specified
if tc.Simulator.Options != nil && tc.Simulator.Options.Connector != "" {
opts.Connector = tc.Simulator.Options.Connector
}
// Build message
inputJSON, err := jsoniter.Marshal(input)
if err != nil {
return nil, fmt.Errorf("failed to marshal simulator input: %w", err)
}
messages := []context.Message{{
Role: context.RoleUser,
Content: string(inputJSON),
}}
// Call simulator
response, err := simAST.Stream(ctx, messages, opts)
if err != nil {
return nil, fmt.Errorf("simulator agent error: %w", err)
}
// Parse response
return r.parseSimulatorResponse(response)
}
// parseSimulatorResponse parses the simulator agent's response
func (r *DynamicRunner) parseSimulatorResponse(response *context.Response) (*SimulatorOutput, error) {
if response == nil || response.Completion == nil {
return nil, fmt.Errorf("empty response from simulator")
}
// Extract content
content := response.Completion.Content
if content == nil {
return nil, fmt.Errorf("no content in simulator response")
}
// Convert to string
var text string
switch v := content.(type) {
case string:
text = v
default:
data, err := jsoniter.Marshal(content)
if err != nil {
return nil, fmt.Errorf("failed to marshal content: %w", err)
}
text = string(data)
}
// Use goutext.ExtractJSON for fault-tolerant parsing
parsed := goutext.ExtractJSON(text)
if parsed == nil {
// Try to use the text as the message directly
return &SimulatorOutput{
Message: text,
GoalAchieved: false,
}, nil
}
// Parse as SimulatorOutput
output := &SimulatorOutput{}
if m, ok := parsed.(map[string]interface{}); ok {
if msg, ok := m["message"].(string); ok {
output.Message = msg
}
if achieved, ok := m["goal_achieved"].(bool); ok {
output.GoalAchieved = achieved
}
if reasoning, ok := m["reasoning"].(string); ok {
output.Reasoning = reasoning
}
}
if output.Message == "" {
return nil, fmt.Errorf("simulator returned empty message")
}
return output, nil
}
// checkCheckpoints validates checkpoints against current response
// It checks both the completion content and tool results for comprehensive validation
func (r *DynamicRunner) checkCheckpoints(checkpoints []*Checkpoint, response *context.Response, result *DynamicResult) []string {
reachedIDs := make([]string, 0)
// Build combined output for checkpoint validation
// This includes both content and tool result messages
combinedOutput := buildCombinedOutput(response)
// Set response on asserter for tool-related assertions
r.asserter.WithResponse(response)
for _, cp := range checkpoints {
cpResult := result.Checkpoints[cp.ID]
if cpResult.Reached {
continue // Already reached
}
// Check "after" constraint
if len(cp.After) > 0 {
allAfterReached := true
for _, afterID := range cp.After {
if afterResult, ok := result.Checkpoints[afterID]; ok {
if !afterResult.Reached {
allAfterReached = false
break
}
}
}
if !allAfterReached {
continue // Dependencies not met
}
}
// Validate using asserter against combined output with full details
tempCase := &Case{Assert: cp.Assert}
assertResult := r.asserter.ValidateWithDetails(tempCase, combinedOutput)
if assertResult.Passed {
cpResult.Reached = true
cpResult.Passed = true
cpResult.ReachedAtTurn = len(result.Turns) + 1
cpResult.Message = assertResult.Message
reachedIDs = append(reachedIDs, cp.ID)
} else {
// Store failure message for debugging (but don't mark as failed yet - it might pass in a later turn)
if cpResult.Message == "" {
cpResult.Message = assertResult.Message
}
}
// Store agent validation details if this is an agent assertion
if isAgentAssertion(cp.Assert) {
// Extract criteria from assertion value
var criteria string
if assertMap, ok := cp.Assert.(map[string]interface{}); ok {
if c, ok := assertMap["value"].(string); ok {
criteria = c
}
}
cpResult.AgentValidation = &AgentValidationResult{
Passed: assertResult.Passed,
Criteria: criteria,
Input: combinedOutput, // Content sent to validator for checking
}
// Extract reason and store full response from validator
if assertResult.Expected != nil {
if validatorResponse, ok := assertResult.Expected.(map[string]interface{}); ok {
if reason, ok := validatorResponse["reason"].(string); ok {
cpResult.AgentValidation.Reason = reason
}
// Store the full validator response
cpResult.AgentValidation.Response = validatorResponse
}
}
}
}
return reachedIDs
}
// isAgentAssertion checks if the assertion is an agent-based assertion
func isAgentAssertion(assert interface{}) bool {
if assertMap, ok := assert.(map[string]interface{}); ok {
if assertType, ok := assertMap["type"].(string); ok {
return assertType == "agent"
}
}
return false
}
// truncateForReport truncates content for report output
func truncateForReport(content interface{}, maxLen int) interface{} {
if content == nil {
return nil
}
str, ok := content.(string)
if !ok {
return content
}
if len(str) <= maxLen {
return str
}
return str[:maxLen] + "... (truncated)"
}
// buildCombinedOutput builds a combined output string from response
// that includes both completion content and tool result messages
func buildCombinedOutput(response *context.Response) string {
if response == nil {
return ""
}
var parts []string
// Add completion content
if response.Completion != nil && response.Completion.Content != nil {
if content := extractContentString(response.Completion.Content); content != "" {
parts = append(parts, content)
}
}
// Add tool result messages
if len(response.Tools) > 0 {
for _, tool := range response.Tools {
if tool.Result != nil {
// Try to extract message from result
if resultMap, ok := tool.Result.(map[string]interface{}); ok {
if msg, exists := resultMap["message"]; exists && msg != nil {
if msgStr, ok := msg.(string); ok && msgStr != "" {
parts = append(parts, msgStr)
}
}
}
}
}
}
// Join all parts with newline for comprehensive matching
return joinNonEmpty(parts, "\n")
}
// extractContentString extracts string content from various types
func extractContentString(content interface{}) string {
if content == nil {
return ""
}
switch v := content.(type) {
case string:
return v
case []interface{}:
// Handle array content (e.g., multimodal content)
var texts []string
for _, item := range v {
if m, ok := item.(map[string]interface{}); ok {
if text, ok := m["text"].(string); ok {
texts = append(texts, text)
}
}
}
return joinNonEmpty(texts, "\n")
default:
// Try to marshal to string
if data, err := jsoniter.MarshalToString(content); err == nil {
return data
}
return ""
}
}
// joinNonEmpty joins non-empty strings with separator
func joinNonEmpty(parts []string, sep string) string {
var nonEmpty []string
for _, p := range parts {
if p != "" {
nonEmpty = append(nonEmpty, p)
}
}
if len(nonEmpty) == 0 {
return ""
}
result := nonEmpty[0]
for i := 1; i < len(nonEmpty); i++ {
result += sep + nonEmpty[i]
}
return result
}
// allRequiredCheckpointsReached checks if all required checkpoints are reached
func (r *DynamicRunner) allRequiredCheckpointsReached(result *DynamicResult) bool {
for _, cp := range result.Checkpoints {
if cp.Required && !cp.Reached {
return false
}
}
return true
}
// buildTurnResponse builds a TurnResponse from the agent response
func buildTurnResponse(response *context.Response) *TurnResponse {
if response == nil {
return nil
}
tr := &TurnResponse{}
// Extract completion content
if response.Completion != nil {
tr.Content = response.Completion.Content
// Extract tool calls from completion
if len(response.Completion.ToolCalls) > 0 {
for _, tc := range response.Completion.ToolCalls {
tr.ToolCalls = append(tr.ToolCalls, ToolCallInfo{
Tool: tc.Function.Name,
Arguments: tc.Function.Arguments,
})
}
}
}
// Add tool results
if len(response.Tools) > 0 {
// If we already have tool calls from completion, match results
if len(tr.ToolCalls) > 0 {
for i, toolResult := range response.Tools {
if i < len(tr.ToolCalls) {
tr.ToolCalls[i].Result = toolResult.Result
}
}
} else {
// Create tool call entries from results
for _, toolResult := range response.Tools {
tr.ToolCalls = append(tr.ToolCalls, ToolCallInfo{
Tool: toolResult.Tool,
Arguments: toolResult.Arguments,
Result: toolResult.Result,
})
}
}
}
// Extract Next hook data
if response.Next != nil && !isEmptyValue(response.Next) {
tr.Next = response.Next
}
return tr
}
// appendAssistantMessages appends assistant messages to the conversation history
// including tool calls and tool results if present
func appendAssistantMessages(messages []context.Message, response *context.Response) []context.Message {
if response == nil {
return messages
}
// Check if there are tool calls in the completion
hasToolCalls := response.Completion != nil && len(response.Completion.ToolCalls) > 0
if hasToolCalls {
// Add assistant message with tool calls
assistantMsg := context.Message{
Role: context.RoleAssistant,
ToolCalls: response.Completion.ToolCalls,
}
// Include content if present
if response.Completion.Content != nil && !isEmptyValue(response.Completion.Content) {
assistantMsg.Content = response.Completion.Content
}
messages = append(messages, assistantMsg)
// Add tool result messages for each tool call
for i, tc := range response.Completion.ToolCalls {
toolCallID := tc.ID
var resultContent string
// Get result from response.Tools if available
if i < len(response.Tools) {
resultJSON, err := jsoniter.MarshalToString(response.Tools[i].Result)
if err == nil {
resultContent = resultJSON
} else {
resultContent = fmt.Sprintf("%v", response.Tools[i].Result)
}
} else {
resultContent = "{}"
}
messages = append(messages, context.Message{
Role: context.RoleTool,
ToolCallID: &toolCallID,
Content: resultContent,
})
}
} else {
// No tool calls, just add content if present
content := extractOutput(response)
if content != nil && !isEmptyValue(content) {
messages = append(messages, context.Message{
Role: context.RoleAssistant,
Content: content,
})
}
}
return messages
}