Merge pull request #1422 from trheyi/main
Enhance Execution with Multi-Turn Conversation and Validation Improvements
This commit is contained in:
commit
600ed9bdc2
19 changed files with 6508 additions and 98 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
655
agent/robot/executor/standard/run_test.go
Normal file
655
agent/robot/executor/standard/run_test.go
Normal 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
|
||||
}
|
||||
394
agent/robot/executor/standard/runner.go
Normal file
394
agent/robot/executor/standard/runner.go
Normal 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()
|
||||
}
|
||||
517
agent/robot/executor/standard/runner_test.go
Normal file
517
agent/robot/executor/standard/runner_test.go
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
840
agent/robot/executor/standard/tasks_test.go
Normal file
840
agent/robot/executor/standard/tasks_test.go
Normal 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
|
||||
699
agent/robot/executor/standard/validator.go
Normal file
699
agent/robot/executor/standard/validator.go
Normal 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
|
||||
}
|
||||
}
|
||||
637
agent/robot/executor/standard/validator_test.go
Normal file
637
agent/robot/executor/standard/validator_test.go
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
471
assert/asserter.go
Normal 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
1078
assert/asserter_test.go
Normal file
File diff suppressed because it is too large
Load diff
174
assert/helpers.go
Normal file
174
assert/helpers.go
Normal 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
94
assert/types.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue