Merge pull request #1422 from trheyi/main

Enhance Execution with Multi-Turn Conversation and Validation Improvements
This commit is contained in:
Max 2026-01-18 10:57:05 +08:00 committed by GitHub
commit 600ed9bdc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 6508 additions and 98 deletions

View file

@ -261,7 +261,7 @@ Human/Event: P1 → P2 → P3 → P4 → P5
| P0 | Inspiration | Clock + Data + News | Report | Clock only |
| P1 | Goal Gen | Report + history | Goals | Always |
| P2 | Task Plan | Goals + tools | Tasks | Always |
| P3 | Validator | Results | Checked results | Always |
| P3 | Run + Valid | Tasks + Experts | TaskResults | Always |
| P4 | Delivery | All results | Email/File | Always |
| P5 | Learning | Summary | KB entries | Always |
@ -367,22 +367,126 @@ type Task struct {
### 4.5 P3: Run
**Architecture:** P3 uses a modular design with three components:
```
┌─────────────────────────────────────────────────────────────┐
│ run.go (P3 Entry) │
│ - RunConfig: ContinueOnFailure, ValidationThreshold, │
│ MaxTurnsPerTask │
│ - RunExecution: main loop with task dependency passing │
└─────────────────────┬───────────────────────────────────────┘
┌────────────┴────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ runner.go │ │ validator.go │
│ - Runner │ │ - Validator │
│ - Multi-turn │ │ - Two-layer │
│ conversation │ │ - Rule+Semantic│
│ - Task context │ │ - NeedReply │
│ building │ │ - ReplyContent │
└────────┬────────┘ └────────┬────────┘
│ │
│ ▼
│ ┌─────────────────┐
│ │ yao/assert │
│ │ - Asserter │
│ │ - 8 types │
│ │ - Extensible │
│ └─────────────────┘
┌─────────────────────────────────────────┐
│ Executor Types │
│ - ExecutorAssistant → Multi-turn AI │
│ - ExecutorMCP → Single-call MCP tool │
│ - ExecutorProcess → Single-call Process │
└─────────────────────────────────────────┘
```
**Execution Flow:**
For each task:
1. Call Assistant or MCP Tool
2. Get result
3. Validate against `ExpectedOutput` and `ValidationRules`
4. Update status
1. **Build Context**: Include previous task results as context
2. **Execute**: Call appropriate executor (Assistant/MCP/Process)
3. **Validate**: Use two-layer validation (rule-based + semantic)
4. **Continue or Complete**:
- For Assistant tasks: If `NeedReply`, continue conversation with `ReplyContent`
- For MCP/Process tasks: Single-call execution, no multi-turn
5. **Update**: Set task status and store result
**Task Dependency**: Previous task results are automatically passed as context to subsequent tasks via `Runner.BuildTaskContext()` and formatted using `FormatPreviousResultsAsContext()`.
**Two-Layer Validation:**
| Layer | Method | Speed | Use Case |
|-------|--------|-------|----------|
| 1. Rule-based | `yao/assert` | Fast | Type check, contains, regex, json_path |
| 2. Semantic | Validation Agent | Slow | ExpectedOutput, complex criteria |
**Executor Types:**
| Type | ExecutorID Format | Example |
|------|-------------------|---------|
| `assistant` | Agent ID | `experts.text-writer` |
| `mcp` | `clientID.toolName` | `filesystem.read_file` |
| `process` | Process name | `models.user.Find` |
**Multi-Turn Conversation Flow:**
For assistant tasks, P3 uses a multi-turn conversation approach:
1. **Call**: Call assistant and get result
2. **Validate**: Validate result (determines: passed, complete, needReply, replyContent)
3. **Reply**: If needReply, continue conversation with replyContent
4. **Repeat**: Until complete or max turns exceeded
The `Validator.ValidateWithContext()` method determines:
- `Complete`: Whether the expected result is obtained
- `NeedReply`: Whether to continue conversation
- `ReplyContent`: What to send in the next turn (validation feedback, clarification request, etc.)
This replaces the traditional retry mechanism with intelligent conversation continuation.
```go
// RunConfig configures P3 execution behavior
type RunConfig struct {
ContinueOnFailure bool // continue to next task even if current fails (default: false)
ValidationThreshold float64 // minimum score to pass validation (default: 0.6)
MaxTurnsPerTask int // max conversation turns per task (default: 10)
}
// ValidationResult with multi-turn conversation support
type ValidationResult struct {
// Basic validation result
Passed bool // overall validation passed
Score float64 // 0-1 confidence score
Issues []string // what failed
Suggestions []string // how to improve
Details string // detailed report (markdown)
// Execution state (for multi-turn conversation control)
Complete bool // whether expected result is obtained
NeedReply bool // whether to continue conversation
ReplyContent string // content for next turn (if NeedReply)
}
```
**yao/assert Package:**
Universal assertion library supporting 8 types:
| Type | Description | Example |
|------|-------------|---------|
| `equals` | Exact match | `{"type": "equals", "value": "success"}` |
| `contains` | Substring check | `{"type": "contains", "value": "total"}` |
| `not_contains` | Negative check | `{"type": "not_contains", "value": "error"}` |
| `json_path` | JSON path extraction | `{"type": "json_path", "path": "data.count", "value": 10}` |
| `regex` | Pattern matching | `{"type": "regex", "value": "^[A-Z].*"}` |
| `type` | Type checking | `{"type": "type", "value": "array"}` |
| `script` | Custom script | `{"type": "script", "script": "scripts.validate"}` |
| `agent` | AI validation | `{"type": "agent", "use": "validator"}` |
### 4.6 P4: Deliver
Send output:
@ -436,7 +540,7 @@ const (
PhaseInspiration Phase = "inspiration" // P0: Clock only
PhaseGoals Phase = "goals" // P1
PhaseTasks Phase = "tasks" // P2
PhaseValidation Phase = "validation" // P3
PhaseRun Phase = "run" // P3 (execution + validation)
PhaseDelivery Phase = "delivery" // P4
PhaseLearning Phase = "learning" // P5
)
@ -444,7 +548,7 @@ const (
// AllPhases for iteration
var AllPhases = []Phase{
PhaseInspiration, PhaseGoals, PhaseTasks,
PhaseValidation, PhaseDelivery, PhaseLearning,
PhaseRun, PhaseDelivery, PhaseLearning,
}
// ClockMode - clock trigger mode enum

View file

@ -44,7 +44,9 @@ yao/agent/robot/
│ │ ├── inspiration.go # P0: Inspiration phase
│ │ ├── goals.go # P1: Goals phase
│ │ ├── tasks.go # P2: Tasks phase
│ │ ├── run.go # P3: Run phase
│ │ ├── run.go # P3: Run phase (main entry)
│ │ ├── runner.go # P3: Task Runner (execution logic)
│ │ ├── validator.go # P3: Validator (two-layer validation)
│ │ ├── delivery.go # P4: Delivery phase
│ │ └── learning.go # P5: Learning phase
│ ├── dryrun/
@ -88,6 +90,11 @@ yao/agent/robot/
└── plan/ # Plan queue (deferred tasks)
├── plan.go # Plan queue struct
└── schedule.go # Schedule for later
yao/assert/ # Universal assertion library (global package)
├── types.go # Assertion, Result, interfaces
├── asserter.go # Asserter implementation (8 assertion types)
└── helpers.go # Utility functions (ExtractPath, ToString, etc.)
```
### Dependency Graph (No Cycles)
@ -140,7 +147,7 @@ yao/agent/robot/
| `trigger/` | `types/` |
| `job/` | `types/`, `yao/job` |
| `plan/` | `types/` |
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `job/` |
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `job/`, `yao/assert` |
| `manager/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` |
| | Manager handles all trigger logic (clock, intervene, event) |
| `api/` | `types/`, `manager/` |
@ -1325,6 +1332,9 @@ type Task struct {
// Validation (defined in P2, used in P3)
ExpectedOutput string `json:"expected_output,omitempty"` // what the task should produce
// ValidationRules supports two formats:
// 1. Natural language: "output must be valid JSON", "must contain 'field'"
// 2. JSON assertions: `{"type": "type", "value": "object"}`, `{"type": "contains", "value": "success"}`
ValidationRules []string `json:"validation_rules,omitempty"` // specific checks to perform
// Runtime
@ -1374,13 +1384,19 @@ type TaskResult struct {
Validation *ValidationResult `json:"validation,omitempty"` // P3 validation result
}
// ValidationResult - P3 semantic validation result
// ValidationResult - P3 validation result with multi-turn conversation support
type ValidationResult struct {
// Basic validation result
Passed bool `json:"passed"` // overall validation passed
Score float64 `json:"score,omitempty"` // 0-1 confidence score
Issues []string `json:"issues,omitempty"` // what failed
Suggestions []string `json:"suggestions,omitempty"` // how to improve
Details string `json:"details,omitempty"` // detailed validation report
Details string `json:"details,omitempty"` // detailed validation report (markdown)
// Execution state (for multi-turn conversation control)
Complete bool `json:"complete"` // whether expected result is obtained
NeedReply bool `json:"need_reply,omitempty"` // whether to continue conversation
ReplyContent string `json:"reply_content,omitempty"` // content for next turn (if NeedReply)
}
// DeliveryResult - P4 delivery output
@ -1759,3 +1775,121 @@ var (
ErrDeliveryFailed = errors.New("delivery failed")
)
```
---
## 5. P3 Implementation Details
### 5.1 Multi-Turn Conversation Flow
For assistant tasks, P3 uses a validator-driven multi-turn conversation:
```
┌──────────────────────────────────────────────────────────────┐
│ executeAssistantWithMultiTurn │
├──────────────────────────────────────────────────────────────┤
│ 1. Create Conversation (single instance for entire task) │
│ 2. Build initial messages with task context │
│ │
│ ┌─────────────────── Turn Loop ───────────────────────────┐ │
│ │ Phase 1: Call assistant via conv.Turn() │ │
│ │ Phase 2: ValidateWithContext() determines: │ │
│ │ - Complete: task done? │ │
│ │ - NeedReply: continue conversation? │ │
│ │ - ReplyContent: what to send next? │ │
│ │ Phase 3: If NeedReply, use ReplyContent as next input │ │
│ │ Break if: Complete && Passed, or !NeedReply │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 3. Return output, validation, error │
└──────────────────────────────────────────────────────────────┘
```
Key points:
- `ValidateWithContext()` returns `NeedReply` and `ReplyContent`
- Conversation continues until `Complete && Passed` or `!NeedReply`
- Max turns controlled by `RunConfig.MaxTurnsPerTask`
### 5.2 Validation Rules Format
Validation rules support two formats:
1. **Natural language**: `"output must be valid JSON"`, `"must contain 'field'"`
2. **Structured JSON**: `{"type": "type", "path": "field", "value": "array"}`
Examples:
```json
// Natural language rules (converted to semantic validation)
"output must be valid JSON"
"must contain product name"
// Structured JSON assertions
{"type": "equals", "value": "success"}
{"type": "contains", "value": "total"}
{"type": "regex", "value": "^[A-Z].*"}
{"type": "json_path", "path": "data.items", "value": 10}
{"type": "type", "path": "result", "value": "object"}
```
### 5.3 Task Dependencies
Task dependencies are handled automatically:
1. `BuildTaskContext()` collects previous task results
2. `FormatPreviousResultsAsContext()` formats them for assistant
```go
// Previous results are passed as context
func (r *Runner) BuildTaskContext(exec *robottypes.Execution, taskIndex int) *RunnerContext {
ctx := &RunnerContext{}
if taskIndex > 0 {
ctx.PreviousResults = exec.Results[:taskIndex]
}
return ctx
}
```
### 5.4 Resource Management
Agent context is properly released to prevent resource leaks:
```go
func (c *AgentCaller) Call(ctx *robottypes.Context, assistantID string, messages []agentcontext.Message) (*CallResult, error) {
agentCtx := c.buildAgentContext(ctx)
defer agentCtx.Release() // IMPORTANT: Release agent context
response, err := ast.Stream(agentCtx, messages, opts)
// ...
}
```
### 5.5 yao/assert Package
The `yao/assert` package is a standalone universal assertion library that can be used by other modules:
```go
import "github.com/yaoapp/yao/assert"
// Create asserter with optional callbacks
asserter := assert.NewAsserter(assert.AssertionOptions{
AgentValidator: myAgentValidator, // for "agent" type assertions
ScriptRunner: myScriptRunner, // for "script" type assertions
})
// Run assertions
results := asserter.Assert(output, []assert.Assertion{
{Type: "type", Value: "object"},
{Type: "contains", Value: "success"},
{Type: "json_path", Path: "data.count", Value: 10},
})
```
Supported assertion types:
- `equals` - exact match
- `contains` - substring check
- `not_contains` - negative substring check
- `json_path` - JSON path extraction and comparison
- `regex` - regex pattern matching
- `type` - type checking (with optional path)
- `script` - custom script validation
- `agent` - AI agent validation

View file

@ -663,82 +663,203 @@ Each phase test uses different expert combinations:
---
## Phase 7: P1 Goals Implementation
## Phase 7: P1 Goals Implementation
**Goal:** Implement P1 (Goal Generation Agent). P0 → P1 → stub P2-P5.
**Depends on:** Phase 6 (P0 Inspiration)
**Status:** COMPLETED
### 7.1 P1 Implementation
- [ ] `executor/goals.go` - `RunGoals(ctx, exec, data)` - real implementation
- [ ] `executor/goals.go` - build prompt with inspiration report
- [ ] `executor/goals.go` - call Goals Agent
- [ ] `executor/goals.go` - parse response to `Goals` struct
- [ ] `executor/goals.go` - handle Human/Event trigger (skip P0, use input directly)
- [x] `executor/goals.go` - `RunGoals(ctx, exec, data)` - real implementation
- [x] `executor/goals.go` - build prompt with inspiration report (Clock trigger)
- [x] `executor/goals.go` - build prompt with trigger input (Human/Event trigger)
- [x] `executor/goals.go` - call Goals Agent using `AgentCaller`
- [x] `executor/goals.go` - parse response to `Goals` struct (JSON with content + delivery)
- [x] `executor/goals.go` - handle Human/Event trigger (skip P0, use input directly)
- [x] `executor/goals.go` - include robot identity in prompt
- [x] `executor/goals.go` - include available resources in prompt
- [x] `executor/goals.go` - `ParseDelivery()` - parse delivery target from JSON
- [x] `executor/goals.go` - `IsValidDeliveryType()` - validate delivery types
### 7.2 Tests
- [ ] `executor/goals_test.go` - P1 with real LLM call
- [ ] Test: inspiration report in prompt (Clock trigger)
- [ ] Test: user input in prompt (Human trigger)
- [ ] Test: goals markdown generated with priorities
- [ ] Test: goals are actionable and measurable
- [x] `executor/goals_test.go` - P1 with real LLM call (14 test cases)
- [x] Test: inspiration report in prompt (Clock trigger)
- [x] Test: user input in prompt (Human trigger)
- [x] Test: event data in prompt (Event trigger)
- [x] Test: goals markdown generated with priorities
- [x] Test: delivery parsing from agent response
- [x] Test: error handling (robot nil, agent not found, empty input)
- [x] Test: fallback behavior (no inspiration → clock context)
- [x] `ParseDelivery()` unit tests (8 test cases covering edge cases)
- [x] `IsValidDeliveryType()` unit tests
### 7.3 Notes
- P1 uses `robot.goals` test agent from `yao-dev-app/assistants/robot/goals/`
- Goals Agent returns JSON: `{ "content": "...", "delivery": {...} }`
- Delivery is optional; if not present or invalid, `Goals.Delivery` is nil
- Available resources (agents, MCP, KB, DB) are passed to agent for achievable goal generation
---
## Phase 8: P2 Tasks Implementation
## Phase 8: P2 Tasks Implementation
**Goal:** Implement P2 (Task Planning Agent). P1 → P2 → stub P3-P5.
**Depends on:** Phase 7 (P1 Goals)
### 8.1 P2 Implementation
**Status:** COMPLETED
- [ ] `executor/tasks.go` - `RunTasks(ctx, exec, data)` - real implementation
- [ ] `executor/tasks.go` - build prompt with goals
- [ ] `executor/tasks.go` - include available tools/agents in prompt
- [ ] `executor/tasks.go` - call Tasks Agent
- [ ] `executor/tasks.go` - parse response to `[]Task` (structured JSON)
- [ ] `executor/tasks.go` - validate task structure
### 8.1 Validation Agent Setup (Prerequisite for P3) ✅
### 8.2 Tests
> **Note:** Validation Agent was already set up in Phase 5.
- [ ] `executor/tasks_test.go` - P2 with real LLM call
- [ ] Test: goals included in prompt
- [ ] Test: available tools listed in prompt
- [ ] Test: structured tasks generated (2-3 tasks per goal)
- [ ] Test: each task has valid executor type and ID
- [x] `robot/validation/package.yao` - Validation Agent config (DeepSeek V3, temperature 0.2)
- [x] `robot/validation/prompts.yml` - validation prompts
- Input: Task result, expected outcome, validation rules
- Output: Validation result (pass/fail, score, issues, suggestions)
### 8.2 P2 Implementation ✅
- [x] `executor/tasks.go` - `RunTasks(ctx, exec, data)` - real implementation
- [x] `executor/tasks.go` - build prompt with goals (using `FormatGoals`)
- [x] `executor/tasks.go` - include available tools/agents in prompt
- [x] `executor/tasks.go` - include delivery target in prompt (for task output format)
- [x] `executor/tasks.go` - call Tasks Agent using `AgentCaller`
- [x] `executor/tasks.go` - parse response to `[]Task` (structured JSON)
- [x] `executor/tasks.go` - validate task structure (executor type, ID, messages)
- [x] `executor/tasks.go` - `ParseTasks()`, `ParseTask()`, `ParseMessages()` helpers
- [x] `executor/tasks.go` - `SortTasksByOrder()` - ensure correct execution sequence
- [x] `executor/tasks.go` - `ValidateExecutorExists()` - optional executor existence check
- [x] `executor/tasks.go` - `ValidateTasksWithResources()` - validation with warnings
- [x] `executor/input.go` - `FormatGoals()` updated to include Delivery Target
### 8.3 Tests ✅
- [x] `executor/tasks_test.go` - P2 with real LLM call (7 integration tests)
- [x] Test: goals included in prompt
- [x] Test: available tools listed in prompt
- [x] Test: delivery target included in prompt
- [x] Test: structured tasks generated
- [x] Test: each task has valid executor type and ID
- [x] Test: each task has expected output and validation rules
- [x] `ParseTasks` unit tests (5 tests)
- [x] `ValidateTasks` unit tests (5 tests)
- [x] `SortTasksByOrder` unit tests (4 tests)
- [x] `ValidateExecutorExists` unit tests (7 tests)
- [x] `ValidateTasksWithResources` unit tests (3 tests)
- [x] `ParseExecutorType` unit tests (5 tests)
- [x] `IsValidExecutorType` unit tests (2 tests)
- [x] `FormatGoals` with delivery target tests (4 tests)
### 8.4 Notes
- Tasks Agent returns JSON: `{ "tasks": [...] }`
- Each task includes: id, executor_type, executor_id, messages, expected_output, validation_rules, order
- Tasks are sorted by `order` field after parsing
- Executor existence is optionally validated (warnings only, doesn't block)
- Delivery target from P1 is passed to P2 so tasks can produce appropriate output format
---
## Phase 9: P3 Run Implementation
## Phase 9: P3 Run Implementation
**Goal:** Implement P3 (Task Execution). P2 → P3 → stub P4-P5.
**Goal:** Implement P3 (Task Execution + Validation). P2 → P3 → stub P4-P5.
**Depends on:** Phase 8 (P2 Tasks)
**Depends on:** Phase 8 (P2 Tasks + Validation Agent)
### 9.1 Implementation
**Status:** Complete
- [ ] `executor/run.go` - `RunExecution(ctx, exec, data)` - real implementation
- [ ] `executor/run.go` - iterate tasks in order
- [ ] `executor/run.go` - dispatch to correct executor (assistant/mcp/process)
- [ ] `executor/run.go` - collect results with timing
- [ ] `executor/run.go` - handle task failures gracefully
- [ ] `executor/run.go` - support pause/resume during execution
### 9.1 Implementation ✅
### 9.2 Validation Agent Setup
- [x] `executor/run.go` - `RunExecution(ctx, exec, data)` - real implementation
- [x] `RunConfig` - configuration (ContinueOnFailure, ValidationThreshold, MaxTurnsPerTask)
- [x] Sequential task execution with progress tracking
- [x] Task status updates (Running → Completed/Failed/Skipped)
- [x] `ContinueOnFailure` option for graceful failure handling
- [x] Previous task results passed as context to subsequent tasks
- [x] `executor/runner.go` - `Runner` struct for task execution
- [x] `ExecuteWithRetry()` - multi-turn conversation flow for assistant tasks
- [x] `executeNonAssistantTask()` - single-call execution for MCP/Process
- [x] `executeAssistantWithMultiTurn()` - AI assistant with conversation support
- [x] `ExecuteMCPTask()` - MCP tool execution (format: `clientID.toolName`)
- [x] `ExecuteProcessTask()` - Yao process execution
- [x] `BuildTaskContext()` - context with previous results
- [x] `BuildAssistantMessages()` - build messages for assistant
- [x] `FormatPreviousResultsAsContext()` - format previous results as context
- [x] `extractOutput()` - extract output from CallResult
- [x] `generateDefaultReply()` - fallback reply generation
- [x] `executor/validator.go` - Two-layer validation system
- [x] Layer 1: Rule-based validation using `yao/assert`
- [x] Layer 2: Semantic validation using Validation Agent
- [x] `ValidateWithContext()` - validation with multi-turn support
- [x] `isComplete()` - determine if expected result is obtained
- [x] `checkNeedReply()` - determine if conversation should continue
- [x] `generateFeedbackReply()` - generate validation feedback for next turn
- [x] `detectNeedMoreInfo()` - detect if assistant needs clarification
- [x] `convertStringRule()` - natural language rules to assertions
- [x] `parseRules()` - JSON and string rule parsing
- [x] `mergeResults()` - combine rule and semantic results
- [ ] `robot/validation/package.yao` - Validation Agent config
- [ ] `robot/validation/prompts.yml` - validation prompts
### 9.2 Assert Package ✅
Created new `yao/assert` package for universal assertion/validation:
- [x] `assert/types.go` - `Assertion`, `Result`, `AssertionOptions` types
- [x] `assert/asserter.go` - `Asserter` with 8 assertion types:
- [x] `equals` - exact match
- [x] `contains` - substring check
- [x] `not_contains` - negative substring check
- [x] `json_path` - JSON path extraction and comparison
- [x] `regex` - regex pattern matching
- [x] `type` - type checking (with optional path)
- [x] `script` - custom script validation
- [x] `agent` - AI agent validation
- [x] `assert/helpers.go` - `ValidateOutput()`, `ExtractPath()`, `ToString()`, `GetType()`
- [x] `assert/asserter_test.go` - 98.7% test coverage
### 9.3 Tests
- [ ] `executor/run_test.go` - P3 with real agent calls
- [ ] Test: tasks executed in order
- [ ] Test: results collected with correct structure
- [ ] Test: task failure doesn't stop entire execution
- [ ] Test: pause/resume works during task execution
**Completed:**
- [x] `assert/asserter_test.go` - 40+ test cases (98.7% coverage)
- [x] `types/robot_test.go` - Task structure tests with validation rules
- [x] `tasks_test.go` - ParseTasks with validation rules format
- [x] Validation rules format aligned with `prompts.yml` guidelines
**Completed Tests:**
- [x] `executor/standard/run_test.go` - P3 RunExecution tests ✅
- [x] Test: tasks executed in order (`TestRunExecutionBasic`)
- [x] Test: task status updates (`TestRunExecutionTaskStatus`)
- [x] Test: remaining tasks marked as skipped on failure
- [x] Test: error handling (robot nil, no tasks, non-existent assistant)
- [x] Test: rule-based and semantic validation (`TestRunExecutionValidation`)
- [x] Test: previous results passed as context to subsequent tasks
- [x] `executor/standard/runner_test.go` - Runner tests ✅
- [x] Test: ExecuteWithRetry with multi-turn conversation flow
- [x] Test: max turns limit enforcement
- [x] Test: BuildTaskContext with previous results
- [x] Test: FormatPreviousResultsAsContext formatting
- [x] Test: BuildAssistantMessages with task content
- [x] Test: FormatMessagesAsText (string, multipart, map)
- [x] Test: MCP and Process tasks (skipped - requires runtime)
- [x] `executor/standard/validator_test.go` - Validator tests ✅
- [x] Test: ValidateWithContext with multi-turn state
- [x] Test: isComplete determination logic
- [x] Test: checkNeedReply scenarios
- [x] Test: convertStringRule for natural language rules
- [x] Test: parseRules for JSON assertions (equals, regex, json_path, type)
- [x] Test: validateSemantic with Validation Agent
- [x] Test: mergeResults logic (rule + semantic)
**Completed:**
- [x] Test: ContinueOnFailure option (run_test.go) ✅
- [x] `stops_on_first_failure_when_ContinueOnFailure_is_false`
- [x] `continues_execution_when_ContinueOnFailure_is_true`
- [x] `multiple_failures_with_ContinueOnFailure`
---
@ -976,9 +1097,9 @@ func TestWithLLM(t *testing.T) {
| 4. Agent Infra | ✅ | AgentCaller, InputFormatter, test assistants |
| 5. Test Scenarios | ✅ | Phase agents (P0-P5), expert agents |
| 6. P0 Inspiration | ✅ | Inspiration Agent integration |
| 7. P1 Goals | | Goal Generation Agent integration |
| 8. P2 Tasks | | Task Planning Agent integration |
| 9. P3 Run | ⬜ | Task execution (assistant/mcp/process) |
| 7. P1 Goals | | Goal Generation Agent integration |
| 8. P2 Tasks | | Task Planning Agent integration |
| 9. P3 Run | ✅ | Task execution + validation + yao/assert + multi-turn conversation |
| 10. P4 Delivery | ⬜ | Output delivery (email/file/webhook/notify) |
| 11. P5 Learning | ⬜ | Learning Agent + KB save |
| 12. API & Integration | ⬜ | Complete API, end-to-end tests |

View file

@ -178,6 +178,7 @@ func (c *AgentCaller) Call(ctx *robottypes.Context, assistantID string, messages
// Convert robot context to agent context
agentCtx := c.buildAgentContext(ctx)
defer agentCtx.Release() // IMPORTANT: Release agent context to prevent resource leaks
// Call assistant with streaming
response, err := ast.Stream(agentCtx, messages, opts)

View file

@ -300,6 +300,23 @@ func (f *InputFormatter) FormatGoals(goals *robottypes.Goals, robot *robottypes.
sb.WriteString(goals.Content)
sb.WriteString("\n")
// Delivery target (from P1) - important for task planning
// Tasks should be designed to produce output suitable for the delivery method
if goals.Delivery != nil {
sb.WriteString("\n## Delivery Target\n\n")
sb.WriteString(fmt.Sprintf("- **Type**: %s\n", goals.Delivery.Type))
if len(goals.Delivery.Recipients) > 0 {
sb.WriteString(fmt.Sprintf("- **Recipients**: %s\n", strings.Join(goals.Delivery.Recipients, ", ")))
}
if goals.Delivery.Format != "" {
sb.WriteString(fmt.Sprintf("- **Format**: %s\n", goals.Delivery.Format))
}
if goals.Delivery.Template != "" {
sb.WriteString(fmt.Sprintf("- **Template**: %s\n", goals.Delivery.Template))
}
sb.WriteString("\n**Note**: Design tasks to produce output suitable for this delivery method.\n")
}
// Available resources - reuse FormatAvailableResources for consistency
resourcesContent := f.FormatAvailableResources(robot)
if resourcesContent != "" {

View file

@ -1,11 +1,38 @@
package standard
import (
"fmt"
"time"
robottypes "github.com/yaoapp/yao/agent/robot/types"
)
// RunConfig configures P3 execution behavior
type RunConfig struct {
// ContinueOnFailure continues to next task even if current task fails (default: false)
ContinueOnFailure bool
// ValidationThreshold is the minimum score to pass validation (default: 0.6)
ValidationThreshold float64
// MaxTurnsPerTask is the maximum conversation turns for multi-turn tasks (default: 10)
// This controls how many times the assistant can be called for a single task
// (including retries with validation feedback)
MaxTurnsPerTask int
}
// DefaultRunConfig returns the default P3 configuration
func DefaultRunConfig() *RunConfig {
return &RunConfig{
ContinueOnFailure: false,
ValidationThreshold: 0.6,
MaxTurnsPerTask: 10,
}
}
// RunExecution executes P3: Run phase
// Executes each task using the appropriate executor (Assistant, Process, or Function)
// Executes each task using the appropriate executor (Assistant, MCP, Process)
// with multi-turn conversation and validation
//
// Input:
// - Tasks (from P2)
@ -13,27 +40,85 @@ import (
// Output:
// - TaskResult for each task with output and validation
//
// Executor Types:
// - ExecutorAssistant: Call AI assistant
// - ExecutorMCP: Call MCP tool
// - ExecutorProcess: Run Yao process
// - ExecutorFunction: Call JavaScript function
//
// TODO: Implement real task execution
func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
e.simulateStreamDelay()
exec.Results = []robottypes.TaskResult{
{
TaskID: "task-1",
Success: true,
Output: map[string]interface{}{"status": "completed"},
Duration: 100,
Validation: &robottypes.ValidationResult{
Passed: true,
Score: 0.95,
},
},
// Execution Flow (per task):
// 1. Call assistant/MCP/process and get result
// 2. Validate result using two-layer validation (rule-based + semantic)
// 3. If validation.NeedReply, continue conversation with validation.ReplyContent
// 4. Repeat until validation.Complete or max turns exceeded
// 5. Pass previous task results as context to next task
func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execution, data interface{}) error {
robot := exec.GetRobot()
if robot == nil {
return fmt.Errorf("robot not found in execution")
}
if len(exec.Tasks) == 0 {
return fmt.Errorf("no tasks to execute")
}
// Get run configuration from data or use default
var config *RunConfig
if cfg, ok := data.(*RunConfig); ok && cfg != nil {
config = cfg
} else {
config = DefaultRunConfig()
}
// Initialize results slice
exec.Results = make([]robottypes.TaskResult, 0, len(exec.Tasks))
// Create task runner
runner := NewRunner(ctx, robot, config)
// Execute tasks sequentially
for i := range exec.Tasks {
task := &exec.Tasks[i]
// Update current state for tracking
exec.Current = &robottypes.CurrentState{
Task: task,
TaskIndex: i,
Progress: fmt.Sprintf("%d/%d tasks", i+1, len(exec.Tasks)),
}
// Mark task as running
task.Status = robottypes.TaskRunning
now := time.Now()
task.StartTime = &now
// Build task context with previous results
taskCtx := runner.BuildTaskContext(exec, i)
// Execute task with multi-turn conversation support
result := runner.ExecuteWithRetry(task, taskCtx)
// Update task status based on result
endTime := time.Now()
task.EndTime = &endTime
// Determine task status from result
// Note: result.Success is already set to (validation.Complete && validation.Passed) in runner
if result.Success {
task.Status = robottypes.TaskCompleted
} else {
task.Status = robottypes.TaskFailed
}
// Store result
exec.Results = append(exec.Results, *result)
// Check if we should continue on failure
if !result.Success && !config.ContinueOnFailure {
// Mark remaining tasks as skipped
for j := i + 1; j < len(exec.Tasks); j++ {
exec.Tasks[j].Status = robottypes.TaskSkipped
}
return fmt.Errorf("task %s failed: %s", task.ID, result.Error)
}
}
// Clear current state
exec.Current = nil
return nil
}

View file

@ -0,0 +1,655 @@
package standard_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/robot/executor/standard"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// ============================================================================
// P3 Run Phase Tests - RunExecution
// ============================================================================
func TestRunExecutionBasic(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("executes single task successfully", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
// Pre-built task (simulating P2 output)
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write a short greeting message for a company newsletter. Keep it under 50 words."},
},
ExpectedOutput: "A friendly greeting message suitable for a newsletter",
Order: 0,
Status: types.TaskPending,
},
}
e := standard.New()
err := e.RunExecution(ctx, exec, nil)
require.NoError(t, err)
require.Len(t, exec.Results, 1)
result := exec.Results[0]
assert.Equal(t, "task-001", result.TaskID)
assert.True(t, result.Success, "task should succeed")
assert.NotNil(t, result.Output, "should have output")
assert.Greater(t, result.Duration, int64(0), "should have duration")
// Task status should be updated
assert.Equal(t, types.TaskCompleted, exec.Tasks[0].Status)
assert.NotNil(t, exec.Tasks[0].StartTime)
assert.NotNil(t, exec.Tasks[0].EndTime)
})
t.Run("executes multiple tasks in order", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
// Multiple tasks that depend on each other
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.data-analyst",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Analyze this data: Sales Q1: $100K, Q2: $150K, Q3: $120K, Q4: $180K. Calculate the total and average."},
},
ExpectedOutput: "JSON with total and average sales figures",
ValidationRules: []string{
"output must be valid JSON",
},
Order: 0,
Status: types.TaskPending,
},
{
ID: "task-002",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.summarizer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Summarize the key findings from the previous analysis in 2-3 sentences."},
},
ExpectedOutput: "A brief summary of the sales analysis",
Order: 1,
Status: types.TaskPending,
},
}
e := standard.New()
err := e.RunExecution(ctx, exec, nil)
require.NoError(t, err)
require.Len(t, exec.Results, 2)
// Both tasks should complete
assert.True(t, exec.Results[0].Success, "first task should succeed")
assert.True(t, exec.Results[1].Success, "second task should succeed")
// Second task should have access to first task's result (via context)
assert.Equal(t, types.TaskCompleted, exec.Tasks[0].Status)
assert.Equal(t, types.TaskCompleted, exec.Tasks[1].Status)
t.Logf("Task 1 output: %v", exec.Results[0].Output)
t.Logf("Task 2 output: %v", exec.Results[1].Output)
})
t.Run("passes previous results as context to subsequent tasks", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
// First task generates data, second task uses it
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Generate a list of 3 product names for a tech company. Output as JSON array."},
},
ExpectedOutput: "JSON array with 3 product names",
Order: 0,
Status: types.TaskPending,
},
{
ID: "task-002",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Using the product names from the previous task, write a one-line tagline for each product."},
},
ExpectedOutput: "Taglines for each product",
Order: 1,
Status: types.TaskPending,
},
}
e := standard.New()
err := e.RunExecution(ctx, exec, nil)
require.NoError(t, err)
require.Len(t, exec.Results, 2)
// Both should succeed
assert.True(t, exec.Results[0].Success)
assert.True(t, exec.Results[1].Success)
// Second task output should reference products from first task
t.Logf("Task 1 (products): %v", exec.Results[0].Output)
t.Logf("Task 2 (taglines): %v", exec.Results[1].Output)
})
}
func TestRunExecutionTaskStatus(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("updates task status during execution", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Say 'Hello World'"},
},
Order: 0,
Status: types.TaskPending,
},
}
// Verify initial status
assert.Equal(t, types.TaskPending, exec.Tasks[0].Status)
e := standard.New()
err := e.RunExecution(ctx, exec, nil)
require.NoError(t, err)
// Verify final status
assert.Equal(t, types.TaskCompleted, exec.Tasks[0].Status)
assert.NotNil(t, exec.Tasks[0].StartTime)
assert.NotNil(t, exec.Tasks[0].EndTime)
})
t.Run("marks remaining tasks as skipped on failure", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
// First task uses a non-existent assistant to guarantee failure
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "non.existent.assistant.xyz123", // Non-existent assistant
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "This will fail"},
},
Order: 0,
Status: types.TaskPending,
},
{
ID: "task-002",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write another greeting"},
},
Order: 1,
Status: types.TaskPending,
},
{
ID: "task-003",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write yet another greeting"},
},
Order: 2,
Status: types.TaskPending,
},
}
e := standard.New()
err := e.RunExecution(ctx, exec, nil)
// Should return error because first task failed
assert.Error(t, err)
assert.Contains(t, err.Error(), "task-001")
// First task should be failed
assert.Equal(t, types.TaskFailed, exec.Tasks[0].Status)
// Remaining tasks should be skipped
assert.Equal(t, types.TaskSkipped, exec.Tasks[1].Status)
assert.Equal(t, types.TaskSkipped, exec.Tasks[2].Status)
})
}
func TestRunExecutionErrorHandling(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("returns error when robot is nil", func(t *testing.T) {
exec := &types.Execution{
ID: "test-exec-1",
TriggerType: types.TriggerClock,
Tasks: []types.Task{
{ID: "task-001", ExecutorID: "test"},
},
}
// Don't set robot
e := standard.New()
err := e.RunExecution(ctx, exec, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "robot not found")
})
t.Run("returns error when no tasks", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
exec.Tasks = []types.Task{} // Empty
e := standard.New()
err := e.RunExecution(ctx, exec, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no tasks")
})
t.Run("returns error for non-existent assistant", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "non.existent.agent",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Test"},
},
Order: 0,
Status: types.TaskPending,
},
}
e := standard.New()
err := e.RunExecution(ctx, exec, nil)
assert.Error(t, err)
// Task should be marked as failed
assert.Equal(t, types.TaskFailed, exec.Tasks[0].Status)
})
}
func TestRunExecutionContinueOnFailure(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("stops on first failure when ContinueOnFailure is false", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
// First task will fail (non-existent assistant), second should be skipped
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "non.existent.assistant.xyz123",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "This will fail"},
},
Order: 0,
Status: types.TaskPending,
},
{
ID: "task-002",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write a greeting"},
},
Order: 1,
Status: types.TaskPending,
},
}
// Use default config (ContinueOnFailure = false)
config := standard.DefaultRunConfig()
assert.False(t, config.ContinueOnFailure)
e := standard.New()
err := e.RunExecution(ctx, exec, config)
// Should return error
assert.Error(t, err)
assert.Contains(t, err.Error(), "task-001")
// Only first task should have a result
assert.Len(t, exec.Results, 1)
// First task failed
assert.Equal(t, types.TaskFailed, exec.Tasks[0].Status)
// Second task should be skipped (not executed)
assert.Equal(t, types.TaskSkipped, exec.Tasks[1].Status)
})
t.Run("continues execution when ContinueOnFailure is true", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
// First task will fail, but second should still execute
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "non.existent.assistant.xyz123",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "This will fail"},
},
Order: 0,
Status: types.TaskPending,
},
{
ID: "task-002",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write a short greeting message"},
},
ExpectedOutput: "A greeting message",
Order: 1,
Status: types.TaskPending,
},
{
ID: "task-003",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write a farewell message"},
},
ExpectedOutput: "A farewell message",
Order: 2,
Status: types.TaskPending,
},
}
// Enable ContinueOnFailure
config := standard.DefaultRunConfig()
config.ContinueOnFailure = true
e := standard.New()
err := e.RunExecution(ctx, exec, config)
// Should NOT return error when ContinueOnFailure is true
assert.NoError(t, err)
// All tasks should have results
assert.Len(t, exec.Results, 3)
// First task failed
assert.Equal(t, types.TaskFailed, exec.Tasks[0].Status)
assert.False(t, exec.Results[0].Success)
// Second and third tasks should have executed and completed
assert.Equal(t, types.TaskCompleted, exec.Tasks[1].Status)
assert.True(t, exec.Results[1].Success)
assert.Equal(t, types.TaskCompleted, exec.Tasks[2].Status)
assert.True(t, exec.Results[2].Success)
t.Logf("Task 1 (failed): %v", exec.Results[0].Error)
t.Logf("Task 2 (success): %v", exec.Results[1].Output)
t.Logf("Task 3 (success): %v", exec.Results[2].Output)
})
t.Run("multiple failures with ContinueOnFailure", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
// Mix of failing and succeeding tasks
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "non.existent.assistant.1",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Fail 1"},
},
Order: 0,
Status: types.TaskPending,
},
{
ID: "task-002",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Say hello"},
},
Order: 1,
Status: types.TaskPending,
},
{
ID: "task-003",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "non.existent.assistant.2",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Fail 2"},
},
Order: 2,
Status: types.TaskPending,
},
{
ID: "task-004",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Say goodbye"},
},
Order: 3,
Status: types.TaskPending,
},
}
config := standard.DefaultRunConfig()
config.ContinueOnFailure = true
e := standard.New()
err := e.RunExecution(ctx, exec, config)
assert.NoError(t, err)
assert.Len(t, exec.Results, 4)
// Check status pattern: fail, success, fail, success
assert.Equal(t, types.TaskFailed, exec.Tasks[0].Status)
assert.Equal(t, types.TaskCompleted, exec.Tasks[1].Status)
assert.Equal(t, types.TaskFailed, exec.Tasks[2].Status)
assert.Equal(t, types.TaskCompleted, exec.Tasks[3].Status)
// Count successes and failures
successCount := 0
failCount := 0
for _, result := range exec.Results {
if result.Success {
successCount++
} else {
failCount++
}
}
assert.Equal(t, 2, successCount)
assert.Equal(t, 2, failCount)
})
}
func TestRunExecutionValidation(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("validates output with rule-based validation", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.data-analyst",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Return a JSON object with fields: name (string), count (number). Example: {\"name\": \"test\", \"count\": 5}"},
},
ExpectedOutput: "JSON object with name and count fields",
ValidationRules: []string{
"output must be valid JSON",
`{"type": "type", "value": "object"}`,
},
Order: 0,
Status: types.TaskPending,
},
}
e := standard.New()
err := e.RunExecution(ctx, exec, nil)
require.NoError(t, err)
require.Len(t, exec.Results, 1)
result := exec.Results[0]
assert.True(t, result.Success)
assert.NotNil(t, result.Validation)
assert.True(t, result.Validation.Passed)
})
t.Run("validates output with semantic validation", func(t *testing.T) {
robot := createRunTestRobot(t)
exec := createRunTestExecution(robot)
exec.Tasks = []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write a professional email greeting for a business context. Start with 'Dear' and end with a comma."},
},
ExpectedOutput: "A professional email greeting starting with 'Dear'",
Order: 0,
Status: types.TaskPending,
},
}
e := standard.New()
err := e.RunExecution(ctx, exec, nil)
require.NoError(t, err)
require.Len(t, exec.Results, 1)
result := exec.Results[0]
assert.True(t, result.Success)
assert.NotNil(t, result.Validation)
t.Logf("Output: %v", result.Output)
t.Logf("Validation: passed=%v, score=%.2f", result.Validation.Passed, result.Validation.Score)
})
}
// ============================================================================
// Helper Functions
// ============================================================================
// createRunTestRobot creates a test robot for P3 run tests
func createRunTestRobot(t *testing.T) *types.Robot {
t.Helper()
return &types.Robot{
MemberID: "test-robot-run",
TeamID: "test-team-1",
DisplayName: "Test Robot for Run",
SystemPrompt: "You are a helpful assistant.",
Config: &types.Config{
Identity: &types.Identity{
Role: "Test Assistant",
Duties: []string{"Execute tasks", "Generate content"},
},
Resources: &types.Resources{
Phases: map[types.Phase]string{
types.PhaseRun: "robot.validation",
"validation": "robot.validation", // For semantic validation agent
},
Agents: []string{
"experts.data-analyst",
"experts.summarizer",
"experts.text-writer",
},
},
},
}
}
// createRunTestExecution creates a test execution for P3 run tests
func createRunTestExecution(robot *types.Robot) *types.Execution {
exec := &types.Execution{
ID: "test-exec-run-1",
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: types.TriggerClock,
StartTime: time.Now(),
Status: types.ExecRunning,
Phase: types.PhaseRun,
Goals: &types.Goals{
Content: "## Goals\n\n1. Execute test tasks",
},
}
exec.SetRobot(robot)
return exec
}

View file

@ -0,0 +1,394 @@
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()
}

View file

@ -0,0 +1,517 @@
package standard_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/robot/executor/standard"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// ============================================================================
// Runner Tests - Multi-Turn Conversation Flow
// ============================================================================
func TestRunnerExecuteWithRetry(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("executes assistant task with multi-turn conversation", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write a haiku about coding. Format: three lines with 5-7-5 syllables."},
},
ExpectedOutput: "A haiku poem about coding",
Status: types.TaskPending,
}
taskCtx := &standard.RunnerContext{
SystemPrompt: robot.SystemPrompt,
}
result := runner.ExecuteWithRetry(task, taskCtx)
assert.True(t, result.Success, "task should succeed")
assert.NotNil(t, result.Output)
assert.NotNil(t, result.Validation)
assert.True(t, result.Validation.Complete)
assert.Greater(t, result.Duration, int64(0))
t.Logf("Output: %v", result.Output)
t.Logf("Validation: passed=%v, complete=%v, score=%.2f",
result.Validation.Passed, result.Validation.Complete, result.Validation.Score)
})
t.Run("handles validation failure with multi-turn retry", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
config.MaxTurnsPerTask = 3 // Limit turns for test
runner := standard.NewRunner(ctx, robot, config)
// Task with strict validation that may require conversation
task := &types.Task{
ID: "task-002",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.data-analyst",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Return a JSON object with exactly these fields: status (string 'ok'), count (number greater than 0)."},
},
ExpectedOutput: "JSON with status='ok' and count>0",
ValidationRules: []string{
"output must be valid JSON",
`{"type": "type", "value": "object"}`,
},
Status: types.TaskPending,
}
taskCtx := &standard.RunnerContext{
SystemPrompt: robot.SystemPrompt,
}
result := runner.ExecuteWithRetry(task, taskCtx)
// Should either succeed or fail gracefully
assert.NotNil(t, result.Validation)
t.Logf("Success: %v, Output: %v", result.Success, result.Output)
t.Logf("Validation: passed=%v, complete=%v, needReply=%v",
result.Validation.Passed, result.Validation.Complete, result.Validation.NeedReply)
})
t.Run("respects max turns limit", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
config.MaxTurnsPerTask = 1 // Only 1 turn allowed
runner := standard.NewRunner(ctx, robot, config)
// Task that requires multiple turns - asking for something incomplete
// then validation will ask for more, but we only allow 1 turn
task := &types.Task{
ID: "task-003",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Say 'hello'"},
},
// Validation will fail because it expects a JSON object
ExpectedOutput: "A JSON object with 'status' and 'data' fields",
ValidationRules: []string{`{"type": "type", "value": "object"}`},
Status: types.TaskPending,
}
taskCtx := &standard.RunnerContext{
SystemPrompt: robot.SystemPrompt,
}
result := runner.ExecuteWithRetry(task, taskCtx)
// With only 1 turn and strict validation, task should not complete successfully
// Either it fails validation or hits max turns
t.Logf("Result: success=%v, error=%s", result.Success, result.Error)
t.Logf("Validation: passed=%v, complete=%v, needReply=%v",
result.Validation.Passed, result.Validation.Complete, result.Validation.NeedReply)
// The test verifies the max turns mechanism works - task either:
// 1. Fails validation (expected with "say hello" vs JSON requirement)
// 2. Or hits max turns if validation requests retry
assert.NotNil(t, result.Validation)
})
}
func TestRunnerBuildTaskContext(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("includes previous results in context", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
exec := &types.Execution{
ID: "test-exec",
MemberID: robot.MemberID,
TeamID: robot.TeamID,
Goals: &types.Goals{
Content: "Test goals",
},
Results: []types.TaskResult{
{
TaskID: "task-001",
Success: true,
Output: map[string]interface{}{"data": "previous result"},
},
{
TaskID: "task-002",
Success: true,
Output: "Another result",
},
},
}
exec.SetRobot(robot)
// Build context for task at index 2 (should include results 0 and 1)
taskCtx := runner.BuildTaskContext(exec, 2)
assert.NotNil(t, taskCtx)
assert.Len(t, taskCtx.PreviousResults, 2)
assert.Equal(t, "task-001", taskCtx.PreviousResults[0].TaskID)
assert.Equal(t, "task-002", taskCtx.PreviousResults[1].TaskID)
assert.Equal(t, robot.SystemPrompt, taskCtx.SystemPrompt)
})
t.Run("handles first task with no previous results", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
exec := &types.Execution{
ID: "test-exec",
MemberID: robot.MemberID,
TeamID: robot.TeamID,
Goals: &types.Goals{
Content: "Test goals",
},
Results: []types.TaskResult{},
}
exec.SetRobot(robot)
taskCtx := runner.BuildTaskContext(exec, 0)
assert.NotNil(t, taskCtx)
assert.Empty(t, taskCtx.PreviousResults)
})
t.Run("handles bounds check for task index", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
exec := &types.Execution{
ID: "test-exec",
MemberID: robot.MemberID,
TeamID: robot.TeamID,
Results: []types.TaskResult{
{TaskID: "task-001", Success: true},
},
}
exec.SetRobot(robot)
// Task index 5, but only 1 result exists
taskCtx := runner.BuildTaskContext(exec, 5)
assert.NotNil(t, taskCtx)
assert.Len(t, taskCtx.PreviousResults, 1) // Should only include available results
})
}
func TestRunnerFormatPreviousResultsAsContext(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("formats previous results as markdown", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
results := []types.TaskResult{
{
TaskID: "task-001",
Success: true,
Output: map[string]interface{}{"key": "value", "count": 42},
},
{
TaskID: "task-002",
Success: false,
Output: "Partial result",
Error: "Validation failed",
},
}
formatted := runner.FormatPreviousResultsAsContext(results)
assert.Contains(t, formatted, "## Previous Task Results")
assert.Contains(t, formatted, "task-001")
assert.Contains(t, formatted, "task-002")
assert.Contains(t, formatted, "Success")
assert.Contains(t, formatted, "Failed")
assert.Contains(t, formatted, "key")
assert.Contains(t, formatted, "value")
t.Logf("Formatted context:\n%s", formatted)
})
t.Run("returns empty string for no results", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
formatted := runner.FormatPreviousResultsAsContext([]types.TaskResult{})
assert.Empty(t, formatted)
})
}
func TestRunnerBuildAssistantMessages(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("builds messages with task content", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write a greeting"},
},
}
taskCtx := &standard.RunnerContext{
SystemPrompt: "You are helpful",
}
messages := runner.BuildAssistantMessages(task, taskCtx)
assert.NotEmpty(t, messages)
// Should contain task message
found := false
for _, msg := range messages {
if content, ok := msg.Content.(string); ok && content == "Write a greeting" {
found = true
break
}
}
assert.True(t, found, "should contain task message")
})
t.Run("includes previous results in messages", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
task := &types.Task{
ID: "task-002",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Continue from previous"},
},
}
taskCtx := &standard.RunnerContext{
PreviousResults: []types.TaskResult{
{TaskID: "task-001", Success: true, Output: "Previous output"},
},
SystemPrompt: "You are helpful",
}
messages := runner.BuildAssistantMessages(task, taskCtx)
assert.NotEmpty(t, messages)
// Should have context message with previous results
formatted := runner.FormatMessagesAsText(messages)
assert.Contains(t, formatted, "Previous Task Results")
})
}
func TestRunnerFormatMessagesAsText(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("formats string content", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
messages := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Hello"},
{Role: agentcontext.RoleUser, Content: "World"},
}
text := runner.FormatMessagesAsText(messages)
assert.Contains(t, text, "Hello")
assert.Contains(t, text, "World")
})
t.Run("handles multipart content", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
messages := []agentcontext.Message{
{
Role: agentcontext.RoleUser,
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "Part 1"},
map[string]interface{}{"type": "text", "text": "Part 2"},
},
},
}
text := runner.FormatMessagesAsText(messages)
assert.Contains(t, text, "Part 1")
assert.Contains(t, text, "Part 2")
})
t.Run("handles map content via JSON", func(t *testing.T) {
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
messages := []agentcontext.Message{
{
Role: agentcontext.RoleUser,
Content: map[string]interface{}{"key": "value"},
},
}
text := runner.FormatMessagesAsText(messages)
assert.Contains(t, text, "key")
assert.Contains(t, text, "value")
})
}
// ============================================================================
// Non-Assistant Task Tests (MCP, Process)
// ============================================================================
func TestRunnerExecuteNonAssistantTask(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("executes MCP task (single-call)", func(t *testing.T) {
// Note: This test requires MCP server to be running
// Skip if MCP is not available
t.Skip("MCP server not available in test environment")
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
task := &types.Task{
ID: "task-mcp",
ExecutorType: types.ExecutorMCP,
ExecutorID: "filesystem.list_directory",
Args: []any{map[string]interface{}{"path": "/tmp"}},
Status: types.TaskPending,
}
taskCtx := &standard.RunnerContext{}
result := runner.ExecuteWithRetry(task, taskCtx)
// MCP tasks are single-call, no multi-turn
t.Logf("MCP result: success=%v, output=%v", result.Success, result.Output)
})
t.Run("executes Process task (single-call)", func(t *testing.T) {
// Note: This test requires a Yao process to be available
// Skip if process is not available
t.Skip("Yao process not available in test environment")
robot := createRunnerTestRobot(t)
config := standard.DefaultRunConfig()
runner := standard.NewRunner(ctx, robot, config)
task := &types.Task{
ID: "task-process",
ExecutorType: types.ExecutorProcess,
ExecutorID: "utils.env.Get",
Args: []any{"PATH"},
Status: types.TaskPending,
}
taskCtx := &standard.RunnerContext{}
result := runner.ExecuteWithRetry(task, taskCtx)
// Process tasks are single-call, no multi-turn
t.Logf("Process result: success=%v, output=%v", result.Success, result.Output)
})
}
// ============================================================================
// Helper Functions
// ============================================================================
// createRunnerTestRobot creates a test robot for runner tests
func createRunnerTestRobot(t *testing.T) *types.Robot {
t.Helper()
return &types.Robot{
MemberID: "test-robot-runner",
TeamID: "test-team-1",
DisplayName: "Test Robot for Runner",
SystemPrompt: "You are a helpful assistant. Follow instructions carefully and provide clear responses.",
Config: &types.Config{
Identity: &types.Identity{
Role: "Test Assistant",
Duties: []string{"Execute tasks", "Generate content"},
},
Resources: &types.Resources{
Phases: map[types.Phase]string{
types.PhaseRun: "robot.validation",
"validation": "robot.validation", // For semantic validation agent
},
Agents: []string{
"experts.data-analyst",
"experts.summarizer",
"experts.text-writer",
},
},
},
}
}
// Note: createRunnerTestExecution is available if needed for future tests
// that require a full Execution object instead of just RunnerContext

View file

@ -1,6 +1,9 @@
package standard
import (
"fmt"
agentcontext "github.com/yaoapp/yao/agent/context"
robottypes "github.com/yaoapp/yao/agent/robot/types"
)
@ -8,25 +11,340 @@ import (
// Calls the Tasks Agent to break down goals into executable tasks
//
// Input:
// - Goals (from P1)
// - Available resources (Agents, MCP tools)
// - Goals (from P1) with markdown content
// - Available resources (Agents, MCP tools, KB, DB)
//
// Output:
// - List of Task objects with executor assignments
//
// TODO: Implement real Agent call
// - List of Task objects with executor assignments, expected outputs, and validation rules
func (e *Executor) RunTasks(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
e.simulateStreamDelay()
exec.Tasks = []robottypes.Task{
{
ID: "task-1",
GoalRef: "Goal 1",
Source: robottypes.TaskSourceAuto,
ExecutorType: robottypes.ExecutorAssistant,
ExecutorID: "default-assistant",
Status: robottypes.TaskPending,
},
// Get robot for resources
robot := exec.GetRobot()
if robot == nil {
return fmt.Errorf("robot not found in execution")
}
// 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()
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
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 -> convert to message if no messages
if len(task.Messages) == 0 {
if desc, ok := data["description"].(string); ok && desc != "" {
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)
}
// 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
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 := 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
}

View file

@ -0,0 +1,840 @@
package standard_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/robot/executor/standard"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// ============================================================================
// P2 Tasks Phase Tests
// ============================================================================
func TestRunTasksBasic(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("generates tasks from goals (clock trigger)", func(t *testing.T) {
// Create robot with tasks agent configured
robot := createTasksTestRobot(t, "robot.tasks")
// Create execution with goals (from P1)
exec := createTasksTestExecution(robot, types.TriggerClock)
exec.Goals = &types.Goals{
Content: `## Goals
1. [High] Analyze Q4 sales data and identify top performing products
- Reason: Need to prepare quarterly report
2. [Normal] Generate a summary report for management
- Reason: Weekly review meeting tomorrow`,
}
// Run tasks phase
e := standard.New()
err := e.RunTasks(ctx, exec, nil)
require.NoError(t, err)
require.NotNil(t, exec.Tasks)
assert.NotEmpty(t, exec.Tasks)
// Verify task structure
for i, task := range exec.Tasks {
t.Logf("Task %d: ID=%s, ExecutorType=%s, ExecutorID=%s", i, task.ID, task.ExecutorType, task.ExecutorID)
assert.NotEmpty(t, task.ID, "task should have ID")
assert.NotEmpty(t, task.ExecutorID, "task should have executor ID")
assert.NotEmpty(t, task.Messages, "task should have messages")
}
})
t.Run("includes expected output and validation rules", func(t *testing.T) {
robot := createTasksTestRobot(t, "robot.tasks")
exec := createTasksTestExecution(robot, types.TriggerClock)
exec.Goals = &types.Goals{
Content: `## Goals
1. [High] Fetch latest news about AI developments
- Reason: Stay updated on industry trends
2. [Normal] Summarize the key findings
- Reason: Share with team`,
}
e := standard.New()
err := e.RunTasks(ctx, exec, nil)
require.NoError(t, err)
require.NotEmpty(t, exec.Tasks)
// Check that at least one task has validation info
hasValidationInfo := false
for _, task := range exec.Tasks {
if task.ExpectedOutput != "" || len(task.ValidationRules) > 0 {
hasValidationInfo = true
t.Logf("Task %s has validation: expected_output=%q, rules=%v",
task.ID, task.ExpectedOutput, task.ValidationRules)
}
}
// Note: LLM might not always include validation rules, so we just log
t.Logf("Tasks have validation info: %v", hasValidationInfo)
})
}
func TestRunTasksHumanTrigger(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("generates tasks from human-triggered goals", func(t *testing.T) {
robot := createTasksTestRobot(t, "robot.tasks")
exec := createTasksTestExecution(robot, types.TriggerHuman)
// Goals from human request (P1 output)
exec.Goals = &types.Goals{
Content: `## Goals
1. [High] Research competitor pricing strategies
- Reason: User requested competitive analysis
2. [Normal] Create comparison report
- Reason: User needs data for presentation`,
}
e := standard.New()
err := e.RunTasks(ctx, exec, nil)
require.NoError(t, err)
require.NotEmpty(t, exec.Tasks)
// Tasks should relate to the goals
for _, task := range exec.Tasks {
t.Logf("Task: %s -> %s", task.ID, task.ExecutorID)
}
})
}
func TestRunTasksWithExpertAgents(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("assigns appropriate expert agents to tasks", func(t *testing.T) {
robot := createTasksTestRobot(t, "robot.tasks")
exec := createTasksTestExecution(robot, types.TriggerClock)
// Goals that require different expert agents
exec.Goals = &types.Goals{
Content: `## Goals
1. [High] Analyze sales data from database
- Reason: Quarterly review needed
- Requires: Data analysis capabilities
2. [Normal] Write executive summary report
- Reason: Management presentation
- Requires: Text generation capabilities
3. [Low] Summarize key findings
- Reason: Quick reference for team
- Requires: Summarization capabilities`,
}
e := standard.New()
err := e.RunTasks(ctx, exec, nil)
require.NoError(t, err)
require.NotEmpty(t, exec.Tasks)
// Log assigned executors
executorCounts := make(map[string]int)
for _, task := range exec.Tasks {
executorCounts[task.ExecutorID]++
t.Logf("Task %s assigned to: %s (%s)", task.ID, task.ExecutorID, task.ExecutorType)
}
// Verify different executors were assigned (not all to same agent)
t.Logf("Executor distribution: %v", executorCounts)
})
}
func TestRunTasksErrorHandling(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("returns error when robot is nil", func(t *testing.T) {
exec := &types.Execution{
ID: "test-exec-1",
TriggerType: types.TriggerClock,
}
// Don't set robot
e := standard.New()
err := e.RunTasks(ctx, exec, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "robot not found")
})
t.Run("returns error when goals not available", func(t *testing.T) {
robot := createTasksTestRobot(t, "robot.tasks")
exec := createTasksTestExecution(robot, types.TriggerClock)
exec.Goals = nil // No goals
e := standard.New()
err := e.RunTasks(ctx, exec, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "goals not available")
})
t.Run("returns error when goals content is empty", func(t *testing.T) {
robot := createTasksTestRobot(t, "robot.tasks")
exec := createTasksTestExecution(robot, types.TriggerClock)
exec.Goals = &types.Goals{Content: ""} // Empty content
e := standard.New()
err := e.RunTasks(ctx, exec, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "goals not available")
})
t.Run("returns error when agent not found", func(t *testing.T) {
robot := &types.Robot{
MemberID: "test-robot-1",
TeamID: "test-team-1",
Config: &types.Config{
Identity: &types.Identity{Role: "Test"},
Resources: &types.Resources{
Phases: map[types.Phase]string{
types.PhaseTasks: "non.existent.agent",
},
},
},
}
exec := createTasksTestExecution(robot, types.TriggerClock)
exec.Goals = &types.Goals{Content: "Test goals"}
e := standard.New()
err := e.RunTasks(ctx, exec, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "call failed")
})
}
// ============================================================================
// ParseTasks Unit Tests
// ============================================================================
func TestParseTasks(t *testing.T) {
t.Run("parses valid tasks array", func(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"id": "task-001",
"goal_ref": "Goal 1",
"executor_type": "agent",
"executor_id": "experts.data-analyst",
"messages": []interface{}{
map[string]interface{}{
"role": "user",
"content": "Analyze sales data",
},
},
"expected_output": "JSON with sales metrics",
"validation_rules": []interface{}{
// Natural language rules (matched by validator)
"output must be valid JSON",
"must contain 'total_sales'",
// Structured rule: check field type
`{"type": "type", "path": "product_rankings", "value": "array"}`,
},
"order": float64(0),
},
map[string]interface{}{
"id": "task-002",
"goal_ref": "Goal 1",
"executor_type": "agent",
"executor_id": "experts.text-writer",
"description": "Generate report from analysis",
"order": float64(1),
},
}
tasks, err := standard.ParseTasks(data)
require.NoError(t, err)
require.Len(t, tasks, 2)
// First task
assert.Equal(t, "task-001", tasks[0].ID)
assert.Equal(t, "Goal 1", tasks[0].GoalRef)
assert.Equal(t, types.ExecutorAssistant, tasks[0].ExecutorType)
assert.Equal(t, "experts.data-analyst", tasks[0].ExecutorID)
assert.Len(t, tasks[0].Messages, 1)
assert.Equal(t, "JSON with sales metrics", tasks[0].ExpectedOutput)
assert.Len(t, tasks[0].ValidationRules, 3)
assert.Equal(t, 0, tasks[0].Order)
// Second task
assert.Equal(t, "task-002", tasks[1].ID)
assert.Equal(t, "experts.text-writer", tasks[1].ExecutorID)
assert.Len(t, tasks[1].Messages, 1) // description converted to message
assert.Equal(t, 1, tasks[1].Order)
})
t.Run("generates ID if missing", func(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"executor_type": "agent",
"executor_id": "experts.summarizer",
"description": "Summarize content",
},
}
tasks, err := standard.ParseTasks(data)
require.NoError(t, err)
require.Len(t, tasks, 1)
assert.Equal(t, "task-001", tasks[0].ID)
})
t.Run("returns error for missing executor_type", func(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"id": "task-001",
"executor_id": "experts.summarizer",
"description": "Summarize content",
},
}
_, err := standard.ParseTasks(data)
assert.Error(t, err)
assert.Contains(t, err.Error(), "missing executor_type")
})
t.Run("returns error for missing executor_id", func(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"id": "task-001",
"executor_type": "agent",
"description": "Summarize content",
},
}
_, err := standard.ParseTasks(data)
assert.Error(t, err)
assert.Contains(t, err.Error(), "missing executor_id")
})
t.Run("handles different executor types", func(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"executor_type": "agent",
"executor_id": "test-agent",
"description": "Agent task",
},
map[string]interface{}{
"executor_type": "assistant",
"executor_id": "test-assistant",
"description": "Assistant task",
},
map[string]interface{}{
"executor_type": "mcp",
"executor_id": "test-mcp",
"description": "MCP task",
},
map[string]interface{}{
"executor_type": "process",
"executor_id": "test-process",
"description": "Process task",
},
}
tasks, err := standard.ParseTasks(data)
require.NoError(t, err)
require.Len(t, tasks, 4)
assert.Equal(t, types.ExecutorAssistant, tasks[0].ExecutorType)
assert.Equal(t, types.ExecutorAssistant, tasks[1].ExecutorType) // assistant -> ExecutorAssistant
assert.Equal(t, types.ExecutorMCP, tasks[2].ExecutorType)
assert.Equal(t, types.ExecutorProcess, tasks[3].ExecutorType)
})
}
func TestValidateTasks(t *testing.T) {
t.Run("validates valid tasks", func(t *testing.T) {
tasks := []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.data-analyst",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Analyze data"},
},
},
{
ID: "task-002",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.text-writer",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write report"},
},
},
}
err := standard.ValidateTasks(tasks)
assert.NoError(t, err)
})
t.Run("returns error for empty tasks", func(t *testing.T) {
tasks := []types.Task{}
err := standard.ValidateTasks(tasks)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no tasks generated")
})
t.Run("returns error for duplicate IDs", func(t *testing.T) {
tasks := []types.Task{
{
ID: "task-001",
ExecutorID: "agent-1",
Messages: []agentcontext.Message{{Content: "test"}},
},
{
ID: "task-001", // duplicate
ExecutorID: "agent-2",
Messages: []agentcontext.Message{{Content: "test"}},
},
}
err := standard.ValidateTasks(tasks)
assert.Error(t, err)
assert.Contains(t, err.Error(), "duplicate task ID")
})
t.Run("returns error for missing executor_id", func(t *testing.T) {
tasks := []types.Task{
{
ID: "task-001",
ExecutorID: "", // missing
Messages: []agentcontext.Message{{Content: "test"}},
},
}
err := standard.ValidateTasks(tasks)
assert.Error(t, err)
assert.Contains(t, err.Error(), "missing executor_id")
})
t.Run("returns error for missing messages", func(t *testing.T) {
tasks := []types.Task{
{
ID: "task-001",
ExecutorID: "agent-1",
Messages: []agentcontext.Message{}, // empty
},
}
err := standard.ValidateTasks(tasks)
assert.Error(t, err)
assert.Contains(t, err.Error(), "missing messages")
})
}
func TestParseExecutorType(t *testing.T) {
t.Run("parses agent", func(t *testing.T) {
assert.Equal(t, types.ExecutorAssistant, standard.ParseExecutorType("agent"))
})
t.Run("parses assistant", func(t *testing.T) {
assert.Equal(t, types.ExecutorAssistant, standard.ParseExecutorType("assistant"))
})
t.Run("parses mcp", func(t *testing.T) {
assert.Equal(t, types.ExecutorMCP, standard.ParseExecutorType("mcp"))
})
t.Run("parses process", func(t *testing.T) {
assert.Equal(t, types.ExecutorProcess, standard.ParseExecutorType("process"))
})
t.Run("defaults to assistant for unknown", func(t *testing.T) {
assert.Equal(t, types.ExecutorAssistant, standard.ParseExecutorType("unknown"))
assert.Equal(t, types.ExecutorAssistant, standard.ParseExecutorType(""))
})
}
func TestIsValidExecutorType(t *testing.T) {
t.Run("valid executor types", func(t *testing.T) {
assert.True(t, standard.IsValidExecutorType(types.ExecutorAssistant))
assert.True(t, standard.IsValidExecutorType(types.ExecutorMCP))
assert.True(t, standard.IsValidExecutorType(types.ExecutorProcess))
})
t.Run("invalid executor types", func(t *testing.T) {
assert.False(t, standard.IsValidExecutorType(types.ExecutorType("invalid")))
assert.False(t, standard.IsValidExecutorType(types.ExecutorType("")))
})
}
func TestSortTasksByOrder(t *testing.T) {
t.Run("sorts tasks by order", func(t *testing.T) {
tasks := []types.Task{
{ID: "task-c", Order: 2},
{ID: "task-a", Order: 0},
{ID: "task-b", Order: 1},
}
standard.SortTasksByOrder(tasks)
assert.Equal(t, "task-a", tasks[0].ID)
assert.Equal(t, "task-b", tasks[1].ID)
assert.Equal(t, "task-c", tasks[2].ID)
})
t.Run("handles already sorted tasks", func(t *testing.T) {
tasks := []types.Task{
{ID: "task-a", Order: 0},
{ID: "task-b", Order: 1},
{ID: "task-c", Order: 2},
}
standard.SortTasksByOrder(tasks)
assert.Equal(t, "task-a", tasks[0].ID)
assert.Equal(t, "task-b", tasks[1].ID)
assert.Equal(t, "task-c", tasks[2].ID)
})
t.Run("handles single task", func(t *testing.T) {
tasks := []types.Task{
{ID: "task-a", Order: 0},
}
standard.SortTasksByOrder(tasks)
assert.Len(t, tasks, 1)
assert.Equal(t, "task-a", tasks[0].ID)
})
t.Run("handles empty tasks", func(t *testing.T) {
tasks := []types.Task{}
standard.SortTasksByOrder(tasks)
assert.Empty(t, tasks)
})
}
func TestValidateExecutorExists(t *testing.T) {
t.Run("returns true for existing agent", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Resources: &types.Resources{
Agents: []string{"experts.data-analyst", "experts.text-writer"},
},
},
}
assert.True(t, standard.ValidateExecutorExists("experts.data-analyst", types.ExecutorAssistant, robot))
assert.True(t, standard.ValidateExecutorExists("experts.text-writer", types.ExecutorAssistant, robot))
})
t.Run("returns false for non-existing agent", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Resources: &types.Resources{
Agents: []string{"experts.data-analyst"},
},
},
}
assert.False(t, standard.ValidateExecutorExists("experts.unknown", types.ExecutorAssistant, robot))
})
t.Run("returns true for existing MCP", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Resources: &types.Resources{
MCP: []types.MCPConfig{
{ID: "database"},
{ID: "email"},
},
},
},
}
assert.True(t, standard.ValidateExecutorExists("database", types.ExecutorMCP, robot))
assert.True(t, standard.ValidateExecutorExists("email", types.ExecutorMCP, robot))
})
t.Run("returns false for non-existing MCP", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Resources: &types.Resources{
MCP: []types.MCPConfig{
{ID: "database"},
},
},
},
}
assert.False(t, standard.ValidateExecutorExists("unknown", types.ExecutorMCP, robot))
})
t.Run("returns true for process (not validated)", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Resources: &types.Resources{},
},
}
assert.True(t, standard.ValidateExecutorExists("models.user.Find", types.ExecutorProcess, robot))
})
t.Run("returns true when robot is nil", func(t *testing.T) {
assert.True(t, standard.ValidateExecutorExists("any", types.ExecutorAssistant, nil))
})
t.Run("returns true when resources is nil", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{},
}
assert.True(t, standard.ValidateExecutorExists("any", types.ExecutorAssistant, robot))
})
}
func TestValidateTasksWithResources(t *testing.T) {
t.Run("returns no warnings for valid tasks", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Resources: &types.Resources{
Agents: []string{"experts.data-analyst", "experts.text-writer"},
},
},
}
tasks := []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.data-analyst",
Messages: []agentcontext.Message{{Content: "test"}},
},
}
warnings, err := standard.ValidateTasksWithResources(tasks, robot)
assert.NoError(t, err)
assert.Empty(t, warnings)
})
t.Run("returns warnings for unknown executor", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Resources: &types.Resources{
Agents: []string{"experts.data-analyst"},
},
},
}
tasks := []types.Task{
{
ID: "task-001",
ExecutorType: types.ExecutorAssistant,
ExecutorID: "experts.unknown",
Messages: []agentcontext.Message{{Content: "test"}},
},
}
warnings, err := standard.ValidateTasksWithResources(tasks, robot)
assert.NoError(t, err)
assert.Len(t, warnings, 1)
assert.Contains(t, warnings[0], "experts.unknown")
assert.Contains(t, warnings[0], "not found")
})
t.Run("returns error for invalid tasks", func(t *testing.T) {
robot := &types.Robot{}
tasks := []types.Task{} // empty
_, err := standard.ValidateTasksWithResources(tasks, robot)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no tasks generated")
})
}
// ============================================================================
// InputFormatter Tests for P2
// ============================================================================
func TestInputFormatterFormatGoalsForTasks(t *testing.T) {
formatter := standard.NewInputFormatter()
t.Run("formats goals with resources", func(t *testing.T) {
goals := &types.Goals{
Content: "## Goals\n\n1. [High] Analyze data\n2. [Normal] Write report",
}
robot := &types.Robot{
MemberID: "test-robot",
Config: &types.Config{
Resources: &types.Resources{
Agents: []string{"experts.data-analyst", "experts.text-writer"},
},
},
}
content := formatter.FormatGoals(goals, robot)
assert.Contains(t, content, "## Goals")
assert.Contains(t, content, "[High] Analyze data")
assert.Contains(t, content, "## Available Resources")
assert.Contains(t, content, "experts.data-analyst")
assert.Contains(t, content, "experts.text-writer")
})
t.Run("formats goals without robot", func(t *testing.T) {
goals := &types.Goals{
Content: "## Goals\n\n1. Test goal",
}
content := formatter.FormatGoals(goals, nil)
assert.Contains(t, content, "## Goals")
assert.Contains(t, content, "Test goal")
assert.NotContains(t, content, "## Available Resources")
})
t.Run("formats goals with delivery target", func(t *testing.T) {
goals := &types.Goals{
Content: "## Goals\n\n1. Generate weekly report",
Delivery: &types.DeliveryTarget{
Type: types.DeliveryEmail,
Recipients: []string{"team@example.com", "manager@example.com"},
Format: "markdown",
Template: "weekly-report",
},
}
robot := &types.Robot{
MemberID: "test-robot",
Config: &types.Config{
Resources: &types.Resources{
Agents: []string{"experts.text-writer"},
},
},
}
content := formatter.FormatGoals(goals, robot)
assert.Contains(t, content, "## Goals")
assert.Contains(t, content, "## Delivery Target")
assert.Contains(t, content, "email")
assert.Contains(t, content, "team@example.com")
assert.Contains(t, content, "manager@example.com")
assert.Contains(t, content, "markdown")
assert.Contains(t, content, "weekly-report")
assert.Contains(t, content, "Design tasks to produce output suitable")
})
t.Run("formats goals without delivery target", func(t *testing.T) {
goals := &types.Goals{
Content: "## Goals\n\n1. Test goal",
Delivery: nil,
}
content := formatter.FormatGoals(goals, nil)
assert.Contains(t, content, "## Goals")
assert.NotContains(t, content, "## Delivery Target")
})
}
// ============================================================================
// Helper Functions
// ============================================================================
// createTasksTestRobot creates a test robot with specified tasks agent
// Includes available expert agents for task assignment
func createTasksTestRobot(t *testing.T, agentID string) *types.Robot {
t.Helper()
return &types.Robot{
MemberID: "test-robot-1",
TeamID: "test-team-1",
DisplayName: "Test Robot",
Config: &types.Config{
Identity: &types.Identity{
Role: "Test Assistant",
Duties: []string{"Testing", "Data Analysis", "Report Generation"},
},
Resources: &types.Resources{
Phases: map[types.Phase]string{
types.PhaseTasks: agentID,
},
// Available expert agents that can be assigned to tasks
Agents: []string{
"experts.data-analyst",
"experts.summarizer",
"experts.text-writer",
"experts.web-reader",
},
},
},
}
}
// createTasksTestExecution creates a test execution for tasks phase
func createTasksTestExecution(robot *types.Robot, trigger types.TriggerType) *types.Execution {
exec := &types.Execution{
ID: "test-exec-tasks-1",
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: trigger,
StartTime: time.Now(),
Status: types.ExecRunning,
Phase: types.PhaseTasks,
}
exec.SetRobot(robot)
return exec
}
// Note: testAuth is defined in goals_test.go in the same package

View file

@ -0,0 +1,699 @@
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"
)
// 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 *RunConfig
asserter *assert.Asserter
}
// NewValidator creates a new task validator
func NewValidator(ctx *robottypes.Context, robot *robottypes.Robot, config *RunConfig) *Validator {
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
}
}

View file

@ -0,0 +1,637 @@
package standard_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/robot/executor/standard"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// ============================================================================
// Validator Tests - Two-Layer Validation System
// ============================================================================
func TestValidatorValidateWithContext(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("validates with no rules - passes with valid output", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "",
ValidationRules: []string{},
}
result := validator.ValidateWithContext(task, "Some output", nil)
assert.True(t, result.Passed)
assert.True(t, result.Complete)
assert.False(t, result.NeedReply)
assert.Equal(t, 1.0, result.Score)
})
t.Run("validates with no rules - incomplete with empty output", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "",
ValidationRules: []string{},
}
result := validator.ValidateWithContext(task, "", nil)
assert.True(t, result.Passed)
assert.False(t, result.Complete) // Empty output = not complete
})
t.Run("validates with rule-based validation - passes", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
`{"type": "contains", "value": "hello"}`,
},
}
result := validator.ValidateWithContext(task, "hello world", nil)
assert.True(t, result.Passed)
assert.True(t, result.Complete)
assert.False(t, result.NeedReply)
})
t.Run("validates with rule-based validation - fails", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
`{"type": "contains", "value": "expected_string"}`,
},
}
result := validator.ValidateWithContext(task, "actual output without expected", nil)
assert.False(t, result.Passed)
assert.False(t, result.Complete)
assert.True(t, result.NeedReply) // Should suggest retry
assert.NotEmpty(t, result.ReplyContent)
assert.NotEmpty(t, result.Issues)
})
t.Run("validates with semantic validation", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "A professional greeting message",
}
result := validator.ValidateWithContext(task, "Dear Sir/Madam, I hope this message finds you well.", nil)
// Semantic validation should pass for this appropriate output
t.Logf("Validation result: passed=%v, complete=%v, score=%.2f",
result.Passed, result.Complete, result.Score)
t.Logf("Issues: %v", result.Issues)
t.Logf("Suggestions: %v", result.Suggestions)
// The semantic validator should recognize this as appropriate
assert.NotNil(t, result)
})
}
func TestValidatorIsComplete(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("complete when passed with valid output", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "",
ValidationRules: []string{},
}
result := validator.ValidateWithContext(task, "Valid output", nil)
assert.True(t, result.Complete)
})
t.Run("not complete when passed but empty output", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "",
ValidationRules: []string{},
}
result := validator.ValidateWithContext(task, "", nil)
assert.False(t, result.Complete)
})
t.Run("not complete when validation failed", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
`{"type": "contains", "value": "MUST_CONTAIN_THIS"}`,
},
}
result := validator.ValidateWithContext(task, "output without required string", nil)
assert.False(t, result.Passed)
assert.False(t, result.Complete)
})
t.Run("not complete when score below threshold", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
config.ValidationThreshold = 0.9 // High threshold
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "A very specific output format that's hard to match exactly",
}
// This output might get a lower score due to semantic mismatch
result := validator.ValidateWithContext(task, "Some generic output", nil)
// If score is below threshold, should not be complete
if result.Passed && result.Score < config.ValidationThreshold {
assert.False(t, result.Complete)
}
})
}
func TestValidatorCheckNeedReply(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("no reply needed when complete", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "",
ValidationRules: []string{},
}
result := validator.ValidateWithContext(task, "Complete output", nil)
assert.True(t, result.Complete)
assert.False(t, result.NeedReply)
assert.Empty(t, result.ReplyContent)
})
t.Run("reply needed when validation failed with suggestions", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
`{"type": "type", "value": "object"}`,
},
}
// String output when object expected
result := validator.ValidateWithContext(task, "not an object", nil)
assert.False(t, result.Passed)
assert.True(t, result.NeedReply)
assert.NotEmpty(t, result.ReplyContent)
// The reply should contain validation feedback about the issue
assert.Contains(t, result.ReplyContent, "did not pass validation")
})
t.Run("reply needed when output is empty but passed", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "",
ValidationRules: []string{},
}
result := validator.ValidateWithContext(task, " ", nil) // Whitespace only
// Passed (no rules) but not complete (empty output)
assert.True(t, result.Passed)
assert.False(t, result.Complete)
// When passed but not complete (empty output), checkNeedReply may or may not
// set NeedReply depending on the implementation details
// Just verify the result is consistent
t.Logf("NeedReply: %v, ReplyContent: %s", result.NeedReply, result.ReplyContent)
})
}
func TestValidatorConvertStringRule(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("converts 'valid JSON' rule", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
"output must be valid JSON",
},
}
// Valid JSON object
result := validator.ValidateWithContext(task, map[string]interface{}{"key": "value"}, nil)
assert.True(t, result.Passed)
// Invalid (string is not an object)
result2 := validator.ValidateWithContext(task, "not json", nil)
assert.False(t, result2.Passed)
})
t.Run("converts 'must contain' rule", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
"must contain 'success'",
},
}
result := validator.ValidateWithContext(task, "Operation was a success!", nil)
assert.True(t, result.Passed)
result2 := validator.ValidateWithContext(task, "Operation failed", nil)
assert.False(t, result2.Passed)
})
t.Run("converts 'not empty' rule", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
"output must not be empty",
},
}
result := validator.ValidateWithContext(task, "Some content", nil)
assert.True(t, result.Passed)
// Note: The "not empty" rule may be converted to semantic validation
// rather than a rule-based assertion, so empty string might still pass
// if semantic validation is lenient
result2 := validator.ValidateWithContext(task, "", nil)
t.Logf("Empty string validation: passed=%v, issues=%v", result2.Passed, result2.Issues)
})
t.Run("converts 'json array' rule", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
"must be json array",
},
}
result := validator.ValidateWithContext(task, []interface{}{"a", "b", "c"}, nil)
assert.True(t, result.Passed)
result2 := validator.ValidateWithContext(task, map[string]interface{}{"key": "value"}, nil)
assert.False(t, result2.Passed)
})
}
func TestValidatorParseRules(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("parses JSON assertion rules", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
`{"type": "equals", "value": "expected"}`,
},
}
result := validator.ValidateWithContext(task, "expected", nil)
assert.True(t, result.Passed)
result2 := validator.ValidateWithContext(task, "different", nil)
assert.False(t, result2.Passed)
})
t.Run("parses regex rules", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
`{"type": "regex", "value": "^[A-Z][a-z]+$"}`,
},
}
result := validator.ValidateWithContext(task, "Hello", nil)
assert.True(t, result.Passed)
result2 := validator.ValidateWithContext(task, "hello", nil)
assert.False(t, result2.Passed)
})
t.Run("parses json_path rules", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
`{"type": "json_path", "path": "data.count", "value": 42}`,
},
}
result := validator.ValidateWithContext(task, map[string]interface{}{
"data": map[string]interface{}{
"count": 42,
},
}, nil)
assert.True(t, result.Passed)
result2 := validator.ValidateWithContext(task, map[string]interface{}{
"data": map[string]interface{}{
"count": 10,
},
}, nil)
assert.False(t, result2.Passed)
})
t.Run("parses type rules with path", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ValidationRules: []string{
`{"type": "type", "path": "items", "value": "array"}`,
},
}
result := validator.ValidateWithContext(task, map[string]interface{}{
"items": []interface{}{"a", "b"},
}, nil)
assert.True(t, result.Passed)
result2 := validator.ValidateWithContext(task, map[string]interface{}{
"items": "not an array",
}, nil)
assert.False(t, result2.Passed)
})
}
func TestValidatorSemanticValidation(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("semantic validation with expected output", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "A JSON object containing user information with name and email fields",
}
output := map[string]interface{}{
"name": "John Doe",
"email": "john@example.com",
}
result := validator.ValidateWithContext(task, output, nil)
t.Logf("Semantic validation: passed=%v, score=%.2f, complete=%v",
result.Passed, result.Score, result.Complete)
t.Logf("Details: %s", result.Details)
// Should pass semantic validation
assert.NotNil(t, result)
})
t.Run("semantic validation with complex criteria", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "A professional email with greeting, body, and signature",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Write a professional email"},
},
}
output := `Dear Mr. Smith,
I hope this email finds you well. I am writing to follow up on our previous conversation regarding the project timeline.
Please let me know if you have any questions.
Best regards,
John Doe`
result := validator.ValidateWithContext(task, output, nil)
t.Logf("Email validation: passed=%v, score=%.2f", result.Passed, result.Score)
// Should recognize this as a valid professional email
assert.NotNil(t, result)
})
}
func TestValidatorMergeResults(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), testAuth())
t.Run("both rule and semantic validation pass", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "A greeting message",
ValidationRules: []string{
`{"type": "contains", "value": "Hello"}`,
},
}
result := validator.ValidateWithContext(task, "Hello, how are you today?", nil)
assert.True(t, result.Passed)
assert.True(t, result.Complete)
})
t.Run("rule passes but semantic fails", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "A formal business letter with proper formatting",
ValidationRules: []string{
`{"type": "contains", "value": "Hello"}`, // This will pass
},
}
// Contains "Hello" but not a formal business letter
result := validator.ValidateWithContext(task, "Hello there buddy!", nil)
// Rule passes, but semantic might not
t.Logf("Merged result: passed=%v, score=%.2f", result.Passed, result.Score)
})
t.Run("rule fails - semantic not run", func(t *testing.T) {
robot := createValidatorTestRobot(t)
config := standard.DefaultRunConfig()
validator := standard.NewValidator(ctx, robot, config)
task := &types.Task{
ID: "task-001",
ExpectedOutput: "Some expected output",
ValidationRules: []string{
`{"type": "contains", "value": "REQUIRED_STRING"}`,
},
}
result := validator.ValidateWithContext(task, "Output without required string", nil)
// Should fail at rule level, semantic not needed
assert.False(t, result.Passed)
assert.False(t, result.Complete)
})
}
// ============================================================================
// Helper Functions
// ============================================================================
// createValidatorTestRobot creates a test robot for validator tests
func createValidatorTestRobot(t *testing.T) *types.Robot {
t.Helper()
return &types.Robot{
MemberID: "test-robot-validator",
TeamID: "test-team-1",
DisplayName: "Test Robot for Validator",
SystemPrompt: "You are a helpful assistant.",
Config: &types.Config{
Identity: &types.Identity{
Role: "Test Assistant",
Duties: []string{"Validate outputs"},
},
Resources: &types.Resources{
Phases: map[types.Phase]string{
types.PhaseRun: "robot.validation",
"validation": "robot.validation", // For semantic validation agent
},
Agents: []string{
"experts.data-analyst",
"experts.text-writer",
},
},
},
}
}

View file

@ -249,11 +249,17 @@ type TaskResult struct {
// ValidationResult - P3 semantic validation result
type ValidationResult struct {
// Basic validation result
Passed bool `json:"passed"` // overall validation passed
Score float64 `json:"score,omitempty"` // 0-1 confidence score
Issues []string `json:"issues,omitempty"` // what failed
Suggestions []string `json:"suggestions,omitempty"` // how to improve
Details string `json:"details,omitempty"` // detailed validation report (markdown)
// Execution state (for multi-turn conversation control)
Complete bool `json:"complete"` // whether expected result is obtained
NeedReply bool `json:"need_reply,omitempty"` // whether to continue conversation
ReplyContent string `json:"reply_content,omitempty"` // content for next turn (if NeedReply)
}
// DeliveryResult - P4 delivery output

View file

@ -399,8 +399,14 @@ func TestTaskStructure(t *testing.T) {
Status: types.TaskPending,
Order: 0,
// P3 validation fields
ExpectedOutput: "JSON with sales_total and growth_rate fields",
ValidationRules: []string{"sales_total > 0", "growth_rate is a percentage"},
ExpectedOutput: "JSON with sales_total and growth_rate fields",
ValidationRules: []string{
// Natural language rules (matched by validator)
"output must be valid JSON",
"must contain 'sales_total'",
// Structured rule: check field type
`{"type": "type", "path": "growth_rate", "value": "number"}`,
},
}
assert.Equal(t, "task1", task.ID)
@ -412,7 +418,7 @@ func TestTaskStructure(t *testing.T) {
assert.Equal(t, 0, task.Order)
// Validation fields
assert.Contains(t, task.ExpectedOutput, "sales_total")
assert.Len(t, task.ValidationRules, 2)
assert.Len(t, task.ValidationRules, 3)
}
func TestGoalsStructure(t *testing.T) {
@ -468,6 +474,65 @@ func TestValidationResultStructure(t *testing.T) {
assert.Len(t, validation.Suggestions, 2)
}
func TestValidationResultMultiTurnFields(t *testing.T) {
// Test new multi-turn conversation control fields
t.Run("complete and passed", func(t *testing.T) {
validation := &types.ValidationResult{
Passed: true,
Score: 0.95,
Complete: true,
}
assert.True(t, validation.Passed)
assert.True(t, validation.Complete)
assert.False(t, validation.NeedReply)
assert.Empty(t, validation.ReplyContent)
})
t.Run("passed but not complete - needs reply", func(t *testing.T) {
validation := &types.ValidationResult{
Passed: true,
Score: 0.7,
Complete: false,
NeedReply: true,
ReplyContent: "Please continue and provide the complete result.",
}
assert.True(t, validation.Passed)
assert.False(t, validation.Complete)
assert.True(t, validation.NeedReply)
assert.NotEmpty(t, validation.ReplyContent)
})
t.Run("failed with suggestions - needs reply", func(t *testing.T) {
validation := &types.ValidationResult{
Passed: false,
Score: 0.3,
Complete: false,
Issues: []string{"Missing required field"},
Suggestions: []string{"Add the field"},
NeedReply: true,
ReplyContent: "## Validation Feedback\n\nPlease fix: Missing required field",
}
assert.False(t, validation.Passed)
assert.False(t, validation.Complete)
assert.True(t, validation.NeedReply)
assert.Contains(t, validation.ReplyContent, "Validation Feedback")
})
t.Run("failed without suggestions - no reply", func(t *testing.T) {
validation := &types.ValidationResult{
Passed: false,
Score: 0.0,
Complete: false,
Issues: []string{"Critical error: invalid format"},
NeedReply: false,
}
assert.False(t, validation.Passed)
assert.False(t, validation.Complete)
assert.False(t, validation.NeedReply)
assert.Empty(t, validation.ReplyContent)
})
}
func TestDeliveryResultStructure(t *testing.T) {
sentAt := time.Now()
delivery := &types.DeliveryResult{

471
assert/asserter.go Normal file
View file

@ -0,0 +1,471 @@
package assert
import (
"fmt"
"regexp"
"strings"
"github.com/yaoapp/gou/text"
)
// Asserter handles assertions/validations
type Asserter struct {
agentValidator AgentValidator
scriptRunner ScriptRunner
}
// New creates a new Asserter
func New() *Asserter {
return &Asserter{}
}
// WithAgentValidator sets the agent validator for agent-type assertions
func (a *Asserter) WithAgentValidator(v AgentValidator) *Asserter {
a.agentValidator = v
return a
}
// WithScriptRunner sets the script runner for script-type assertions
func (a *Asserter) WithScriptRunner(r ScriptRunner) *Asserter {
a.scriptRunner = r
return a
}
// Validate validates output against a list of assertions
// Returns (passed, error message)
func (a *Asserter) Validate(assertions []*Assertion, output interface{}) (bool, string) {
if len(assertions) == 0 {
return true, ""
}
var failures []string
for _, assertion := range assertions {
result := a.Evaluate(assertion, output, nil)
if !result.Passed {
msg := result.Message
if assertion.Message != "" {
msg = assertion.Message
}
failures = append(failures, msg)
}
}
if len(failures) > 0 {
return false, strings.Join(failures, "; ")
}
return true, ""
}
// ValidateWithDetails validates output and returns detailed results
func (a *Asserter) ValidateWithDetails(assertions []*Assertion, output interface{}) *Result {
if len(assertions) == 0 {
return &Result{Passed: true}
}
if len(assertions) == 1 {
return a.Evaluate(assertions[0], output, nil)
}
var failures []string
for _, assertion := range assertions {
result := a.Evaluate(assertion, output, nil)
if !result.Passed {
msg := result.Message
if assertion.Message != "" {
msg = assertion.Message
}
failures = append(failures, msg)
}
}
if len(failures) > 0 {
return &Result{
Passed: false,
Message: strings.Join(failures, "; "),
}
}
return &Result{Passed: true}
}
// Evaluate evaluates a single assertion
func (a *Asserter) Evaluate(assertion *Assertion, output, input interface{}) *Result {
result := &Result{
Assertion: assertion,
Expected: assertion.Value,
}
switch assertion.Type {
case "equals", "":
result = a.assertEquals(assertion, output)
case "contains":
result = a.assertContains(assertion, output)
case "not_contains":
result = a.assertNotContains(assertion, output)
case "json_path":
result = a.assertJSONPath(assertion, output)
case "regex":
result = a.assertRegex(assertion, output)
case "type":
result = a.assertType(assertion, output)
case "script":
result = a.assertScript(assertion, output, input)
case "agent":
result = a.assertAgent(assertion, output, input)
default:
result.Passed = false
result.Message = fmt.Sprintf("unknown assertion type: %s", assertion.Type)
}
// Apply negate
if assertion.Negate {
result.Passed = !result.Passed
if result.Passed {
result.Message = "negated assertion passed"
} else {
result.Message = "negated: " + result.Message
}
}
return result
}
// assertEquals checks for exact equality
func (a *Asserter) assertEquals(assertion *Assertion, output interface{}) *Result {
result := &Result{
Assertion: assertion,
Actual: output,
Expected: assertion.Value,
}
if ValidateOutput(output, assertion.Value) {
result.Passed = true
result.Message = "values are equal"
} else {
result.Passed = false
result.Message = fmt.Sprintf("expected %v, got %v", assertion.Value, output)
}
return result
}
// assertContains checks if output contains the expected value
func (a *Asserter) assertContains(assertion *Assertion, output interface{}) *Result {
result := &Result{
Assertion: assertion,
Actual: output,
Expected: assertion.Value,
}
outputStr := ToString(output)
expectedStr := ToString(assertion.Value)
if strings.Contains(outputStr, expectedStr) {
result.Passed = true
result.Message = fmt.Sprintf("output contains '%s'", expectedStr)
} else {
result.Passed = false
result.Message = fmt.Sprintf("output does not contain '%s'", expectedStr)
}
return result
}
// assertNotContains checks if output does not contain the expected value
func (a *Asserter) assertNotContains(assertion *Assertion, output interface{}) *Result {
result := a.assertContains(assertion, output)
result.Passed = !result.Passed
if result.Passed {
result.Message = fmt.Sprintf("output does not contain '%s'", ToString(assertion.Value))
} else {
result.Message = fmt.Sprintf("output should not contain '%s'", ToString(assertion.Value))
}
return result
}
// assertJSONPath extracts a value using JSON path and compares
func (a *Asserter) assertJSONPath(assertion *Assertion, output interface{}) *Result {
result := &Result{
Assertion: assertion,
Expected: assertion.Value,
}
// Convert output to JSON if needed
var jsonData interface{}
switch v := output.(type) {
case string:
extracted := text.ExtractJSON(v)
if extracted != nil {
jsonData = extracted
} else {
result.Passed = false
result.Message = fmt.Sprintf("output is not valid JSON: %s", TruncateOutput(v, 100))
return result
}
case map[string]interface{}, []interface{}:
jsonData = v
default:
result.Passed = false
result.Message = fmt.Sprintf("output is not a JSON object or array, got: %T", output)
return result
}
// Extract value using path
path := strings.TrimPrefix(assertion.Path, "$.")
actual := ExtractPath(jsonData, path)
result.Actual = actual
// Compare
if ValidateOutput(actual, assertion.Value) {
result.Passed = true
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
return result
}
// IN semantics: if expected is array, check if actual matches any element
if expectedArr, ok := assertion.Value.([]interface{}); ok {
if _, actualIsArr := actual.([]interface{}); !actualIsArr {
for _, expectedItem := range expectedArr {
if ValidateOutput(actual, expectedItem) {
result.Passed = true
result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path)
return result
}
}
}
}
result.Passed = false
result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual)
return result
}
// assertRegex checks if output matches a regex pattern
func (a *Asserter) assertRegex(assertion *Assertion, output interface{}) *Result {
result := &Result{
Assertion: assertion,
Actual: output,
Expected: assertion.Value,
}
pattern, ok := assertion.Value.(string)
if !ok {
result.Passed = false
result.Message = "regex pattern must be a string"
return result
}
re, err := regexp.Compile(pattern)
if err != nil {
result.Passed = false
result.Message = fmt.Sprintf("invalid regex pattern: %s", err.Error())
return result
}
outputStr := ToString(output)
if re.MatchString(outputStr) {
result.Passed = true
result.Message = fmt.Sprintf("output matches pattern '%s'", pattern)
} else {
result.Passed = false
result.Message = fmt.Sprintf("output does not match pattern '%s'", pattern)
}
return result
}
// assertType checks the type of the output (or a nested field if path is specified)
func (a *Asserter) assertType(assertion *Assertion, output interface{}) *Result {
result := &Result{
Assertion: assertion,
Expected: assertion.Value,
}
expectedType, ok := assertion.Value.(string)
if !ok {
result.Passed = false
result.Message = "type assertion value must be a string"
return result
}
// If path is specified, extract the value first
var valueToCheck interface{} = output
if assertion.Path != "" {
// Convert output to JSON if needed
var jsonData interface{}
switch v := output.(type) {
case string:
extracted := text.ExtractJSON(v)
if extracted != nil {
jsonData = extracted
} else {
result.Passed = false
result.Message = fmt.Sprintf("output is not valid JSON: %s", TruncateOutput(v, 100))
return result
}
case map[string]interface{}, []interface{}:
jsonData = v
default:
result.Passed = false
result.Message = fmt.Sprintf("output is not a JSON object or array, got: %T", output)
return result
}
// Extract value using path
path := strings.TrimPrefix(assertion.Path, "$.")
valueToCheck = ExtractPath(jsonData, path)
if valueToCheck == nil {
result.Passed = false
result.Actual = nil
result.Message = fmt.Sprintf("path '%s' not found in output", assertion.Path)
return result
}
}
result.Actual = valueToCheck
actualType := GetType(valueToCheck)
if actualType == expectedType {
result.Passed = true
if assertion.Path != "" {
result.Message = fmt.Sprintf("path '%s' is of type '%s'", assertion.Path, expectedType)
} else {
result.Message = fmt.Sprintf("output is of type '%s'", expectedType)
}
} else {
result.Passed = false
if assertion.Path != "" {
result.Message = fmt.Sprintf("path '%s': expected type '%s', got '%s'", assertion.Path, expectedType, actualType)
} else {
result.Message = fmt.Sprintf("expected type '%s', got '%s'", expectedType, actualType)
}
}
return result
}
// assertScript runs a custom assertion script
func (a *Asserter) assertScript(assertion *Assertion, output, input interface{}) *Result {
result := &Result{
Assertion: assertion,
Actual: output,
}
if a.scriptRunner == nil {
result.Passed = false
result.Message = "script assertions require a ScriptRunner to be configured"
return result
}
scriptName := assertion.Script
if scriptName == "" {
result.Passed = false
result.Message = "script assertion requires a script name"
return result
}
passed, message, err := a.scriptRunner.Run(scriptName, output, input, assertion.Value)
if err != nil {
result.Passed = false
result.Message = fmt.Sprintf("script execution failed: %s", err.Error())
return result
}
result.Passed = passed
result.Message = message
return result
}
// assertAgent uses an agent to validate the output
func (a *Asserter) assertAgent(assertion *Assertion, output, input interface{}) *Result {
result := &Result{
Assertion: assertion,
Actual: output,
}
if a.agentValidator == nil {
result.Passed = false
result.Message = "agent assertions require an AgentValidator to be configured"
return result
}
// Parse use field: "agents:validator"
if !strings.HasPrefix(assertion.Use, "agents:") {
result.Passed = false
result.Message = "agent assertion requires 'use' field with 'agents:' prefix"
return result
}
agentID := strings.TrimPrefix(assertion.Use, "agents:")
return a.agentValidator.Validate(agentID, output, input, assertion.Value, assertion.Options)
}
// ParseAssertions parses assertion definitions into Assertion objects
func ParseAssertions(input interface{}) []*Assertion {
if input == nil {
return nil
}
var assertions []*Assertion
switch v := input.(type) {
case map[string]interface{}:
assertion := mapToAssertion(v)
if assertion != nil {
assertions = append(assertions, assertion)
}
case []interface{}:
for _, item := range v {
if m, ok := item.(map[string]interface{}); ok {
assertion := mapToAssertion(m)
if assertion != nil {
assertions = append(assertions, assertion)
}
}
}
case string:
assertions = append(assertions, &Assertion{Type: v})
}
return assertions
}
// mapToAssertion converts a map to an Assertion
func mapToAssertion(m map[string]interface{}) *Assertion {
assertion := &Assertion{}
if t, ok := m["type"].(string); ok {
assertion.Type = t
}
if v, ok := m["value"]; ok {
assertion.Value = v
}
if p, ok := m["path"].(string); ok {
assertion.Path = p
}
if s, ok := m["script"].(string); ok {
assertion.Script = s
}
if u, ok := m["use"].(string); ok {
assertion.Use = u
}
if msg, ok := m["message"].(string); ok {
assertion.Message = msg
}
if n, ok := m["negate"].(bool); ok {
assertion.Negate = n
}
if opts, ok := m["options"].(map[string]interface{}); ok {
assertion.Options = &AssertionOptions{}
if c, ok := opts["connector"].(string); ok {
assertion.Options.Connector = c
}
if meta, ok := opts["metadata"].(map[string]interface{}); ok {
assertion.Options.Metadata = meta
}
}
return assertion
}

1078
assert/asserter_test.go Normal file

File diff suppressed because it is too large Load diff

174
assert/helpers.go Normal file
View file

@ -0,0 +1,174 @@
package assert
import (
"encoding/json"
"fmt"
"strconv"
"strings"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/gou/text"
)
// ValidateOutput compares two values for equality using JSON serialization
func ValidateOutput(actual, expected interface{}) bool {
actualJSON, err1 := jsoniter.Marshal(actual)
expectedJSON, err2 := jsoniter.Marshal(expected)
if err1 != nil || err2 != nil {
return false
}
return string(actualJSON) == string(expectedJSON)
}
// ToString converts a value to string for comparison
func ToString(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case []byte:
return string(val)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}
// GetType returns the type name of a value
func GetType(v interface{}) string {
if v == nil {
return "null"
}
switch v.(type) {
case string:
return "string"
case float64, float32, int, int64, int32:
return "number"
case bool:
return "boolean"
case []interface{}:
return "array"
case map[string]interface{}:
return "object"
default:
return fmt.Sprintf("%T", v)
}
}
// ExtractPath extracts a value from JSON using dot-notation path with array index support
// Supports: "field", "field.nested", "field[0]", "field[0].nested", "field.nested[0].value"
func ExtractPath(data interface{}, path string) interface{} {
current := data
segments := ParsePathSegments(path)
for _, segment := range segments {
if segment == "" {
continue
}
// Check if this is an array index like "[0]"
if strings.HasPrefix(segment, "[") && strings.HasSuffix(segment, "]") {
indexStr := segment[1 : len(segment)-1]
index, err := strconv.Atoi(indexStr)
if err != nil {
return nil
}
arr, ok := current.([]interface{})
if !ok {
return nil
}
if index < 0 || index >= len(arr) {
return nil
}
current = arr[index]
} else {
// Regular field access
switch v := current.(type) {
case map[string]interface{}:
current = v[segment]
default:
return nil
}
}
}
return current
}
// ParsePathSegments splits a path like "wheres[0].like" into ["wheres", "[0]", "like"]
func ParsePathSegments(path string) []string {
var segments []string
var current strings.Builder
for i := 0; i < len(path); i++ {
ch := path[i]
switch ch {
case '.':
if current.Len() > 0 {
segments = append(segments, current.String())
current.Reset()
}
case '[':
if current.Len() > 0 {
segments = append(segments, current.String())
current.Reset()
}
j := i + 1
for j < len(path) && path[j] != ']' {
j++
}
if j < len(path) {
segments = append(segments, path[i:j+1])
i = j
}
default:
current.WriteByte(ch)
}
}
if current.Len() > 0 {
segments = append(segments, current.String())
}
return segments
}
// TruncateOutput truncates output for error messages
func TruncateOutput(output interface{}, maxLen int) string {
var s string
switch v := output.(type) {
case string:
s = v
case nil:
return "<nil>"
default:
bytes, err := jsoniter.Marshal(v)
if err != nil {
s = fmt.Sprintf("%v", v)
} else {
s = string(bytes)
}
}
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// ExtractJSON extracts JSON from text (handles markdown code blocks, etc.)
func ExtractJSON(content string) interface{} {
return text.ExtractJSON(content)
}

94
assert/types.go Normal file
View file

@ -0,0 +1,94 @@
// Package assert provides a universal assertion/validation library for Yao.
// It can be used by agent/robot, flow, pipe, widget, and other modules.
//
// Design:
// - Independent implementation (no dependency on agent/test)
// - Supports both rule-based and semantic validation
// - Extensible through interfaces (AgentValidator, ScriptRunner)
package assert
// Assertion represents a single assertion rule
type Assertion struct {
// Type is the assertion type:
// - "equals": exact match (default if expected is set)
// - "contains": output contains the expected string/value
// - "not_contains": output does not contain the string/value
// - "json_path": extract value using JSON path and compare
// - "regex": match output against regex pattern
// - "type": check output type (string, object, array, number, boolean)
// - "script": run a custom assertion script (requires ScriptRunner)
// - "agent": use an agent to validate (requires AgentValidator)
Type string `json:"type"`
// Value is the expected value or pattern (depends on type)
Value interface{} `json:"value,omitempty"`
// Path is the JSON path for json_path assertions (e.g., "$.count", "items[0].name")
Path string `json:"path,omitempty"`
// Script is the script/process name for script assertions
Script string `json:"script,omitempty"`
// Use specifies the agent for validation (e.g., "agents:validator")
Use string `json:"use,omitempty"`
// Options for agent-driven assertions
Options *AssertionOptions `json:"options,omitempty"`
// Message is a custom failure message
Message string `json:"message,omitempty"`
// Negate inverts the assertion result
Negate bool `json:"negate,omitempty"`
}
// AssertionOptions for agent-driven assertions
type AssertionOptions struct {
// Connector overrides the agent's default connector
Connector string `json:"connector,omitempty"`
// Metadata contains custom data passed to the validator
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// Result represents the result of an assertion
type Result struct {
// Passed indicates whether the assertion passed
Passed bool `json:"passed"`
// Message describes the assertion result
Message string `json:"message,omitempty"`
// Assertion is the original assertion that was evaluated
Assertion *Assertion `json:"assertion,omitempty"`
// Actual is the actual value that was compared
Actual interface{} `json:"actual,omitempty"`
// Expected is the expected value
Expected interface{} `json:"expected,omitempty"`
}
// AgentValidator is an interface for agent-based validation
// Implementations should call an AI agent to perform semantic validation
type AgentValidator interface {
// Validate validates output using an agent
// agentID: the agent identifier (e.g., "validator")
// output: the output to validate
// input: the original input (for context)
// criteria: validation criteria from assertion.Value
// options: assertion options
Validate(agentID string, output, input, criteria interface{}, options *AssertionOptions) *Result
}
// ScriptRunner is an interface for running assertion scripts
// Implementations should call a Yao process to perform validation
type ScriptRunner interface {
// Run runs an assertion script
// scriptName: the script/process name
// output: the output to validate
// input: the original input
// expected: the expected value from assertion.Value
// Returns (passed, message, error)
Run(scriptName string, output, input, expected interface{}) (bool, string, error)
}