Merge pull request #1421 from trheyi/main
Refactor Executor Architecture and Update Documentation
This commit is contained in:
commit
7b6ba53abb
49 changed files with 5803 additions and 1007 deletions
|
|
@ -416,7 +416,8 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
|
|||
req := http.New(url).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("Authorization", fmt.Sprintf("Bearer %s", key)).
|
||||
SetHeader("Accept", "text/event-stream")
|
||||
SetHeader("Accept", "text/event-stream").
|
||||
SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)")
|
||||
|
||||
// Accumulate response data
|
||||
accumulator := &streamAccumulator{
|
||||
|
|
@ -945,7 +946,8 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
|
|||
// Create HTTP request with proxy support
|
||||
req := http.New(url).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
|
||||
SetHeader("Authorization", fmt.Sprintf("Bearer %s", key)).
|
||||
SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)")
|
||||
|
||||
// Make request
|
||||
resp := req.Post(requestBody)
|
||||
|
|
|
|||
|
|
@ -79,7 +79,33 @@ flowchart TB
|
|||
KB -.->|History| P0
|
||||
```
|
||||
|
||||
### 2.2 Team Structure
|
||||
### 2.2 Executor Modes
|
||||
|
||||
Executor supports multiple execution modes for different use cases:
|
||||
|
||||
| Mode | Use Case | Status |
|
||||
| -------- | --------------------------------------- | ------------------ |
|
||||
| Standard | Production with real Agent calls | ✅ Implemented |
|
||||
| DryRun | Tests, demos, preview without LLM calls | ✅ Implemented |
|
||||
| Sandbox | Container-isolated for untrusted code | ⬜ Not Implemented |
|
||||
|
||||
**Standard Mode:** Real execution with LLM calls, Job integration, full phase execution.
|
||||
|
||||
**DryRun Mode:** Simulated execution without LLM calls. Used for:
|
||||
|
||||
- Unit tests and integration tests
|
||||
- Demo and preview modes
|
||||
- Scheduling and concurrency testing
|
||||
|
||||
**Sandbox Mode (Future):** Container-level isolation (Docker/gVisor/Firecracker) for:
|
||||
|
||||
- Untrusted robot configurations
|
||||
- Multi-tenant environments
|
||||
- Resource-limited execution
|
||||
|
||||
> **⚠️ Sandbox requires infrastructure support.** Current placeholder behaves like DryRun.
|
||||
|
||||
### 2.3 Team Structure
|
||||
|
||||
Uses existing `__yao.member` model (`yao/models/member.mod.yao`):
|
||||
|
||||
|
|
@ -283,8 +309,24 @@ type ClockContext struct {
|
|||
|
||||
**For Human/Event:** Uses the input directly as goals (or to generate goals).
|
||||
|
||||
```go
|
||||
type Goals struct {
|
||||
Content string // markdown text (for LLM)
|
||||
Delivery *DeliveryTarget // where to send results (for P4)
|
||||
}
|
||||
|
||||
type DeliveryTarget struct {
|
||||
Type DeliveryType // email | webhook | report | notification
|
||||
Recipients []string // email addresses, webhook URLs, user IDs
|
||||
Format string // markdown | html | json | text
|
||||
Template string // template name
|
||||
Options map[string]interface{}
|
||||
}
|
||||
```
|
||||
|
||||
**Example prompt:**
|
||||
|
||||
```
|
||||
Prompt:
|
||||
You are [Sales Manager]. Your job: [track KPIs, make reports].
|
||||
|
||||
## Report
|
||||
|
|
@ -300,20 +342,26 @@ You are [Sales Manager]. Your job: [track KPIs, make reports].
|
|||
Make today's goals.
|
||||
```
|
||||
|
||||
**Note:** Validation criteria (`ExpectedOutput`, `ValidationRules`) are defined at the **Task level** (P2), not Goals level. This allows each task to have specific validation rules for P3.
|
||||
|
||||
### 4.4 P2: Tasks
|
||||
|
||||
P2 Agent reads Goals markdown and breaks into executable tasks:
|
||||
|
||||
```go
|
||||
type Task struct {
|
||||
ID string // unique task ID
|
||||
Messages []context.Message // original input (text, images, files, audio)
|
||||
GoalRef string // reference to goal (e.g., "Goal 1")
|
||||
Source TaskSource // auto | human | event
|
||||
ExecutorType ExecutorType // assistant | mcp | process
|
||||
ExecutorID string // agent ID or mcp tool name
|
||||
Args []any // arguments for executor
|
||||
Order int // execution order
|
||||
ID string // unique task ID
|
||||
Messages []context.Message // original input (text, images, files, audio)
|
||||
GoalRef string // reference to goal (e.g., "Goal 1")
|
||||
Source TaskSource // auto | human | event
|
||||
ExecutorType ExecutorType // assistant | mcp | process
|
||||
ExecutorID string // agent ID or mcp tool name
|
||||
Args []any // arguments for executor
|
||||
Order int // execution order
|
||||
|
||||
// Validation criteria (used in P3)
|
||||
ExpectedOutput string // what the task should produce
|
||||
ValidationRules []string // specific checks to perform
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -323,9 +371,18 @@ For each task:
|
|||
|
||||
1. Call Assistant or MCP Tool
|
||||
2. Get result
|
||||
3. Validate
|
||||
3. Validate against `ExpectedOutput` and `ValidationRules`
|
||||
4. Update status
|
||||
|
||||
```go
|
||||
type ValidationResult struct {
|
||||
Passed bool // overall validation passed
|
||||
Score float64 // 0-1 confidence score
|
||||
Issues []string // what failed
|
||||
Suggestions []string // how to improve
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 P4: Deliver
|
||||
|
||||
Send output:
|
||||
|
|
@ -365,6 +422,7 @@ type Config struct {
|
|||
Resources *Resources `json:"resources"`
|
||||
Delivery *Delivery `json:"delivery"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Executor *Executor `json:"executor,omitempty"` // executor mode settings
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -504,6 +562,23 @@ type Delivery struct {
|
|||
Opts map[string]interface{} `json:"opts"`
|
||||
}
|
||||
|
||||
// ExecutorMode - executor mode enum
|
||||
type ExecutorMode string
|
||||
|
||||
const (
|
||||
ExecutorStandard ExecutorMode = "standard" // real Agent calls (default)
|
||||
ExecutorDryRun ExecutorMode = "dryrun" // simulated, no LLM calls
|
||||
ExecutorSandbox ExecutorMode = "sandbox" // container-isolated (NOT IMPLEMENTED)
|
||||
)
|
||||
|
||||
// Executor - executor settings
|
||||
type Executor struct {
|
||||
Mode ExecutorMode `json:"mode,omitempty"` // standard | dryrun | sandbox
|
||||
MaxDuration string `json:"max_duration,omitempty"` // max execution time (e.g., "30m")
|
||||
}
|
||||
// Note: Sandbox mode requires container infrastructure (Docker/gVisor).
|
||||
// Current implementation falls back to DryRun behavior.
|
||||
|
||||
// Monitor
|
||||
```
|
||||
|
||||
|
|
@ -561,6 +636,10 @@ Example record in `__yao.member` table:
|
|||
"delivery": {
|
||||
"type": "email",
|
||||
"opts": { "to": ["manager@company.com"] }
|
||||
},
|
||||
"executor": {
|
||||
"mode": "standard",
|
||||
"max_duration": "30m"
|
||||
}
|
||||
},
|
||||
"agents": ["data-analyst", "chart-gen"],
|
||||
|
|
@ -811,18 +890,20 @@ const (
|
|||
// - trigger.ExecutionController - pause/resume/stop execution
|
||||
|
||||
type InterveneRequest struct {
|
||||
TeamID string
|
||||
MemberID string
|
||||
Action InterventionAction // task.add | goal.adjust | task.cancel | plan.add | instruct
|
||||
Messages []context.Message // user input (text, images, files)
|
||||
PlanTime *time.Time // for action=plan.add
|
||||
TeamID string
|
||||
MemberID string
|
||||
Action InterventionAction // task.add | goal.adjust | task.cancel | plan.add | instruct
|
||||
Messages []context.Message // user input (text, images, files)
|
||||
PlanTime *time.Time // for action=plan.add
|
||||
ExecutorMode ExecutorMode // optional: standard | dryrun (override robot config)
|
||||
}
|
||||
|
||||
type EventRequest struct {
|
||||
MemberID string
|
||||
Source string // webhook path or table name
|
||||
EventType string // lead.created, etc.
|
||||
Data map[string]interface{}
|
||||
MemberID string
|
||||
Source string // webhook path or table name
|
||||
EventType string // lead.created, etc.
|
||||
Data map[string]interface{}
|
||||
ExecutorMode ExecutorMode // optional: standard | dryrun (override robot config)
|
||||
}
|
||||
|
||||
type ExecutionResult struct {
|
||||
|
|
@ -986,6 +1067,37 @@ quota:
|
|||
priority: 5 # 1-10
|
||||
```
|
||||
|
||||
### Executor
|
||||
|
||||
```yaml
|
||||
# Standard mode (default) - real Agent calls
|
||||
executor:
|
||||
mode: standard
|
||||
max_duration: 30m
|
||||
|
||||
# DryRun mode - simulated execution (for testing/demos)
|
||||
executor:
|
||||
mode: dryrun
|
||||
|
||||
# Sandbox mode (NOT IMPLEMENTED) - container-isolated
|
||||
# Requires Docker/gVisor infrastructure
|
||||
# executor:
|
||||
# mode: sandbox
|
||||
# max_duration: 10m
|
||||
```
|
||||
|
||||
**API Override:**
|
||||
|
||||
```javascript
|
||||
// Override executor mode per trigger
|
||||
const result = Process("robot.Trigger", "mem_abc123", {
|
||||
type: "human",
|
||||
action: "task.add",
|
||||
messages: [{ role: "user", content: "Test task" }],
|
||||
executor_mode: "dryrun", // override robot config
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Examples
|
||||
|
|
|
|||
|
|
@ -32,17 +32,25 @@ yao/agent/robot/
|
|||
│ ├── queue.go # Priority queue
|
||||
│ └── worker.go # Worker goroutines
|
||||
│
|
||||
├── executor/ # Executor package
|
||||
│ ├── executor.go # Executor struct, Execute
|
||||
│ ├── phase.go # RunPhase dispatcher
|
||||
│ ├── inspiration.go # P0: Inspiration (clock only)
|
||||
│ ├── goals.go # P1: Goal generation
|
||||
│ ├── tasks.go # P2: Task planning
|
||||
│ ├── run.go # P3: Task execution
|
||||
│ ├── delivery.go # P4: Delivery
|
||||
│ ├── learning.go # P5: Learning
|
||||
│ ├── agent.go # Call assistant/agent unified method
|
||||
│ └── prompt.go # Prompt building helpers
|
||||
├── executor/ # Executor package (pluggable architecture)
|
||||
│ ├── executor.go # Factory functions, unified entry
|
||||
│ ├── types/
|
||||
│ │ ├── types.go # Executor interface, Config types
|
||||
│ │ └── helpers.go # Shared helper functions
|
||||
│ ├── standard/
|
||||
│ │ ├── executor.go # Real Agent execution (production)
|
||||
│ │ ├── agent.go # AgentCaller for LLM calls
|
||||
│ │ ├── input.go # InputFormatter for prompts
|
||||
│ │ ├── inspiration.go # P0: Inspiration phase
|
||||
│ │ ├── goals.go # P1: Goals phase
|
||||
│ │ ├── tasks.go # P2: Tasks phase
|
||||
│ │ ├── run.go # P3: Run phase
|
||||
│ │ ├── delivery.go # P4: Delivery phase
|
||||
│ │ └── learning.go # P5: Learning phase
|
||||
│ ├── dryrun/
|
||||
│ │ └── executor.go # Simulated execution (testing/demo)
|
||||
│ └── sandbox/
|
||||
│ └── executor.go # Container-isolated (NOT IMPLEMENTED)
|
||||
│
|
||||
├── utils/ # Utility functions
|
||||
│ ├── convert.go # Type conversions (JSON, map, struct)
|
||||
|
|
@ -269,6 +277,9 @@ type TriggerRequest struct {
|
|||
Source types.EventSource `json:"source,omitempty"` // webhook | database
|
||||
EventType string `json:"event_type,omitempty"` // lead.created, order.paid, etc.
|
||||
Data map[string]interface{} `json:"data,omitempty"` // event payload
|
||||
|
||||
// Executor mode (optional, overrides robot config)
|
||||
ExecutorMode types.ExecutorMode `json:"executor_mode,omitempty"` // standard | dryrun
|
||||
}
|
||||
|
||||
// InsertPosition - where to insert task in queue
|
||||
|
|
@ -553,8 +564,15 @@ interface TriggerRequest {
|
|||
source?: "webhook" | "database";
|
||||
event_type?: string; // lead.created, etc.
|
||||
data?: Record<string, any>;
|
||||
|
||||
// Executor mode (optional, overrides robot config)
|
||||
executor_mode?: "standard" | "dryrun"; // sandbox not implemented
|
||||
}
|
||||
|
||||
// ExecutorMode - executor mode type
|
||||
type ExecutorMode = "standard" | "dryrun" | "sandbox";
|
||||
// Note: "sandbox" requires container infrastructure, falls back to "dryrun"
|
||||
|
||||
interface ListQuery {
|
||||
team_id?: string;
|
||||
status?: "idle" | "working" | "paused" | "error" | "maintenance";
|
||||
|
|
@ -1269,7 +1287,7 @@ type CurrentState struct {
|
|||
Progress string `json:"progress,omitempty"` // human-readable progress (e.g., "2/5 tasks")
|
||||
}
|
||||
|
||||
// Goals - P1 output (markdown for LLM)
|
||||
// Goals - P1 output (markdown for LLM + structured metadata)
|
||||
// P1 Agent reads InspirationReport and generates goals as markdown
|
||||
// Example:
|
||||
// ## Goals
|
||||
|
|
@ -1280,7 +1298,17 @@ type CurrentState struct {
|
|||
// 3. [Low] Update CRM with new leads
|
||||
// - Reason: 3 pending leads from yesterday
|
||||
type Goals struct {
|
||||
Content string `json:"content"` // markdown text
|
||||
Content string `json:"content"` // markdown text
|
||||
Delivery *DeliveryTarget `json:"delivery,omitempty"` // where to send results (for P4)
|
||||
}
|
||||
|
||||
// DeliveryTarget - where to deliver results (defined in P1, used in P4)
|
||||
type DeliveryTarget struct {
|
||||
Type DeliveryType `json:"type"` // email | webhook | report | notification
|
||||
Recipients []string `json:"recipients,omitempty"` // email addresses, webhook URLs, user IDs
|
||||
Format string `json:"format,omitempty"` // markdown | html | json | text
|
||||
Template string `json:"template,omitempty"` // template name or inline template
|
||||
Options map[string]interface{} `json:"options,omitempty"` // channel-specific options
|
||||
}
|
||||
|
||||
// Task - planned task (structured, for execution)
|
||||
|
|
@ -1295,6 +1323,10 @@ type Task struct {
|
|||
ExecutorID string `json:"executor_id"`
|
||||
Args []any `json:"args,omitempty"`
|
||||
|
||||
// Validation (defined in P2, used in P3)
|
||||
ExpectedOutput string `json:"expected_output,omitempty"` // what the task should produce
|
||||
ValidationRules []string `json:"validation_rules,omitempty"` // specific checks to perform
|
||||
|
||||
// Runtime
|
||||
Status TaskStatus `json:"status"`
|
||||
Order int `json:"order"` // execution order (0-based)
|
||||
|
|
@ -1334,20 +1366,32 @@ const (
|
|||
|
||||
// TaskResult - task execution result
|
||||
type TaskResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Success bool `json:"success"`
|
||||
Output interface{} `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Duration int64 `json:"duration_ms"`
|
||||
Validated bool `json:"validated"`
|
||||
TaskID string `json:"task_id"`
|
||||
Success bool `json:"success"`
|
||||
Output interface{} `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Duration int64 `json:"duration_ms"`
|
||||
Validation *ValidationResult `json:"validation,omitempty"` // P3 validation result
|
||||
}
|
||||
|
||||
// DeliveryResult - delivery output
|
||||
// ValidationResult - P3 semantic validation result
|
||||
type ValidationResult struct {
|
||||
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
|
||||
}
|
||||
|
||||
// DeliveryResult - P4 delivery output
|
||||
type DeliveryResult struct {
|
||||
Type DeliveryType `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Details interface{} `json:"details,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Type DeliveryType `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Recipients []string `json:"recipients,omitempty"` // who received
|
||||
Content string `json:"content,omitempty"` // formatted content delivered
|
||||
Details interface{} `json:"details,omitempty"` // channel-specific response
|
||||
Error string `json:"error,omitempty"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
}
|
||||
|
||||
// LearningEntry - knowledge to save
|
||||
|
|
@ -1458,22 +1502,33 @@ import (
|
|||
// InterveneRequest - human intervention request
|
||||
// Processed by Manager.Intervene()
|
||||
type InterveneRequest struct {
|
||||
TeamID string `json:"team_id"`
|
||||
MemberID string `json:"member_id"`
|
||||
Action InterventionAction `json:"action"` // task.add, goal.adjust, etc.
|
||||
Messages []agentcontext.Message `json:"messages,omitempty"` // user input (text, images, files)
|
||||
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan.add
|
||||
TeamID string `json:"team_id"`
|
||||
MemberID string `json:"member_id"`
|
||||
Action InterventionAction `json:"action"` // task.add, goal.adjust, etc.
|
||||
Messages []agentcontext.Message `json:"messages,omitempty"` // user input (text, images, files)
|
||||
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan.add
|
||||
ExecutorMode ExecutorMode `json:"executor_mode,omitempty"` // optional: standard | dryrun
|
||||
}
|
||||
|
||||
// EventRequest - event trigger request
|
||||
// Processed by Manager.HandleEvent()
|
||||
type EventRequest struct {
|
||||
MemberID string `json:"member_id"`
|
||||
Source string `json:"source"` // webhook path or table name
|
||||
EventType string `json:"event_type"` // lead.created, etc.
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
MemberID string `json:"member_id"`
|
||||
Source string `json:"source"` // webhook path or table name
|
||||
EventType string `json:"event_type"` // lead.created, etc.
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
ExecutorMode ExecutorMode `json:"executor_mode,omitempty"` // optional: standard | dryrun
|
||||
}
|
||||
|
||||
// ExecutorMode - executor mode enum
|
||||
type ExecutorMode string
|
||||
|
||||
const (
|
||||
ExecutorStandard ExecutorMode = "standard" // real Agent calls (default)
|
||||
ExecutorDryRun ExecutorMode = "dryrun" // simulated, no LLM calls
|
||||
ExecutorSandbox ExecutorMode = "sandbox" // container-isolated (NOT IMPLEMENTED)
|
||||
)
|
||||
|
||||
// ExecutionResult - trigger result
|
||||
type ExecutionResult struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
|
|
|
|||
|
|
@ -309,29 +309,52 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub)
|
|||
- [x] `job/log_test.go` - 24 test cases
|
||||
- [x] All tests passing with real database
|
||||
|
||||
### ✅ 3.6 Executor Stub Enhancement (COMPLETE)
|
||||
### ✅ 3.6 Executor Architecture (COMPLETE)
|
||||
|
||||
- [x] `executor/executor.go` - enhanced stub implementation
|
||||
- [x] `Execute()` - simulate full execution with Job integration
|
||||
1. Create Execution record + Job (via job package)
|
||||
2. Update phase: P0 → P1 → P2 → P3 → P4 → P5
|
||||
3. Log phase transitions
|
||||
4. Return success with mock data
|
||||
- [x] `Config` struct with `SkipJobIntegration`, `OnPhaseStart`, `OnPhaseEnd`
|
||||
- [x] `NewWithDelay()`, `NewWithCallback()` for testing
|
||||
- [x] Quota check with `robot.TryAcquireSlot()`
|
||||
- [x] Clock trigger: P0→P5, Human/Event trigger: P1→P5
|
||||
- [x] Phase-specific files (modular design for Phase 4+ replacement):
|
||||
- [x] `executor/inspiration.go` - `RunInspiration()` P0 mock
|
||||
- [x] `executor/goals.go` - `RunGoals()` P1 mock
|
||||
- [x] `executor/tasks.go` - `RunTasks()` P2 mock
|
||||
- [x] `executor/run.go` - `RunExecution()` P3 mock
|
||||
- [x] `executor/delivery.go` - `RunDelivery()` P4 mock
|
||||
- [x] `executor/learning.go` - `RunLearning()` P5 mock
|
||||
- [x] `simulateStreamDelay()` - 50ms hardcoded delay per phase
|
||||
- [x] Test: smoke tests for basic flow verification
|
||||
- [x] `executor/executor_test.go` - 6 test cases
|
||||
- [x] Clock/Human/Event triggers, nil robot, simulated failure, counters
|
||||
Pluggable executor architecture with multiple execution modes:
|
||||
|
||||
```
|
||||
executor/
|
||||
├── types/
|
||||
│ ├── types.go # Executor interface, Config types
|
||||
│ └── helpers.go # Shared helper functions
|
||||
├── standard/
|
||||
│ ├── executor.go # Real Agent execution (production)
|
||||
│ ├── agent.go # AgentCaller for LLM calls
|
||||
│ ├── input.go # InputFormatter for prompts
|
||||
│ ├── inspiration.go # P0: Inspiration phase
|
||||
│ ├── goals.go # P1: Goals phase
|
||||
│ ├── tasks.go # P2: Tasks phase
|
||||
│ ├── run.go # P3: Run phase
|
||||
│ ├── delivery.go # P4: Delivery phase
|
||||
│ └── learning.go # P5: Learning phase
|
||||
├── dryrun/
|
||||
│ └── executor.go # Simulated execution (testing/demo)
|
||||
├── sandbox/
|
||||
│ └── executor.go # Container-isolated (NOT IMPLEMENTED)
|
||||
└── executor.go # Factory functions
|
||||
```
|
||||
|
||||
**Execution Modes:**
|
||||
|
||||
| Mode | Use Case | Status |
|
||||
| -------- | -------------------------------- | ------------------ |
|
||||
| Standard | Production with real Agent calls | ✅ Implemented |
|
||||
| DryRun | Tests, demos, scheduling tests | ✅ Implemented |
|
||||
| Sandbox | Container-isolated execution | ⬜ Not Implemented |
|
||||
|
||||
> **⚠️ Sandbox Mode:** Requires container-level isolation (Docker/gVisor/Firecracker)
|
||||
> for true security. Current placeholder behaves like DryRun. Future feature.
|
||||
|
||||
- [x] `executor/types/types.go` - `Executor` interface, `PhaseExecutor` interface
|
||||
- [x] `executor/types/helpers.go` - `BuildTriggerInput()` shared helper
|
||||
- [x] `executor/executor.go` - Factory functions (`New`, `NewDryRun`, `NewWithMode`)
|
||||
- [x] `executor/standard/executor.go` - Real execution with Job integration
|
||||
- [x] `executor/standard/phases.go` - Phase implementations (P0-P5)
|
||||
- [x] `executor/dryrun/executor.go` - Simulated execution with callbacks
|
||||
- [x] `executor/sandbox/executor.go` - Placeholder (NOT IMPLEMENTED)
|
||||
- [x] Manager integration - accepts `Executor` interface via config
|
||||
- [x] Tests use DryRun mode for scheduling/concurrency tests
|
||||
|
||||
### 3.7 Integration Test (End-to-End Scheduling) ✅
|
||||
|
||||
|
|
@ -369,164 +392,429 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub)
|
|||
|
||||
---
|
||||
|
||||
## Phase 4: Executor - P0 Inspiration
|
||||
## Phase 4: Agent Call Infrastructure ✅
|
||||
|
||||
**Goal:** Implement unified Agent/Assistant calling mechanism. This is the foundation for all phase implementations (P0-P5).
|
||||
|
||||
**Architecture Note:**
|
||||
|
||||
- **Prompt construction is handled by Assistant layer** (`prompts.yml` in each assistant)
|
||||
- **Executor only prepares input data** (ClockContext, InspirationReport, etc.) and calls Assistant
|
||||
- **Assistant framework handles** prompt rendering, LLM API calls, streaming
|
||||
|
||||
**Implemented:**
|
||||
|
||||
1. A unified way to call assistants with streaming support
|
||||
2. Input data formatting for each phase
|
||||
3. Response parsing (markdown and structured data via `gou/text`)
|
||||
4. Multi-turn conversation support
|
||||
|
||||
### 4.1 Agent Caller Implementation ✅
|
||||
|
||||
- [x] `executor/agent.go` - `AgentCaller` struct with `SkipOutput`, `SkipHistory`, `SkipSearch`, `ChatID`
|
||||
- [x] `executor/agent.go` - `Call(ctx, assistantID, messages)` - basic call with full response
|
||||
- [x] `executor/agent.go` - `CallWithMessages(ctx, assistantID, userContent)` - convenience method
|
||||
- [x] `executor/agent.go` - `CallWithSystemAndUser(ctx, assistantID, systemContent, userContent)`
|
||||
- [x] `executor/agent.go` - handle assistant not found error
|
||||
- [x] `executor/agent.go` - handle LLM API errors gracefully
|
||||
- [x] `executor/agent.go` - `CallResult.GetJSON()` / `GetJSONArray()` - parse JSON response using `gou/text`
|
||||
- [x] `executor/agent.go` - `Conversation` struct for multi-turn dialogues
|
||||
- [x] `executor/agent.go` - `Conversation.Turn()`, `RunUntil()`, `Reset()`, `WithSystemPrompt()`
|
||||
- [x] `executor/agent.go` - Use `agentcontext.Noop()` logger to suppress debug output
|
||||
|
||||
### 4.2 Input Formatters ✅
|
||||
|
||||
- [x] `executor/input.go` - `FormatClockContext(clockCtx, robot)` - format clock context as message content
|
||||
- [x] `executor/input.go` - `FormatInspirationReport(report)` - format P0 output for P1 input
|
||||
- [x] `executor/input.go` - `FormatTriggerInput(input)` - format Human/Event trigger for P1 input
|
||||
- [x] `executor/input.go` - `FormatGoals(goals, robot)` - format P1 output for P2 input
|
||||
- [x] `executor/input.go` - `FormatTasks(tasks)` - format P2 output for P3 input
|
||||
- [x] `executor/input.go` - `FormatTaskResults(results)` - format P3 output for P4/P5 input
|
||||
- [x] `executor/input.go` - `FormatExecutionSummary(exec)` - format full execution for P5 input
|
||||
- [x] `executor/input.go` - `BuildMessages()`, `BuildMessagesWithSystem()` - helper methods
|
||||
|
||||
### 4.3 Test Assistants ✅
|
||||
|
||||
- [x] `yao-dev-app/assistants/tests/robot-single/` - Single-turn test assistant
|
||||
- [x] `yao-dev-app/assistants/tests/robot-conversation/` - Multi-turn conversation test assistant
|
||||
|
||||
### 4.4 Tests ✅
|
||||
|
||||
- [x] `executor/agent_test.go` - 22 test cases for AgentCaller and Conversation
|
||||
- [x] `executor/input_test.go` - 20 test cases for InputFormatter
|
||||
- [x] Verify: assistant can be called and returns response
|
||||
- [x] Verify: multi-turn conversation maintains state
|
||||
- [x] Verify: input data is well-formatted for assistant prompts
|
||||
- [x] Verify: JSON/YAML extraction from LLM output works correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Test Scenario & Assistants Setup ✅
|
||||
|
||||
**Goal:** Create realistic test scenarios with all required assistants.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 6 Generic Phase Agents (P0-P5) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ inspiration │ goals │ tasks │ validation │ delivery │ learning │
|
||||
│ (P0) │ (P1) │ (P2) │ (P3) │ (P4) │ (P5) │
|
||||
└───────────────┴─────────┴─────────┴──────────────┴────────────┴─────────────┘
|
||||
↓ P2 assigns tasks to
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Expert Agents (Task Executors) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ text-writer │ web-reader │ data-analyst │ summarizer │ ... │
|
||||
│ (Generate) │ (Fetch URL) │ (Analyze) │ (Summarize) │ │
|
||||
└───────────────┴──────────────┴────────────────┴──────────────┴─────────────┘
|
||||
```
|
||||
|
||||
**Test Strategy:**
|
||||
|
||||
- Phase Agents (P0-P5) are **generic** and reusable across all robot types
|
||||
- Expert Agents are **specialized** for specific tasks (text, web, data, etc.)
|
||||
- Each P0-P5 test uses **different expert combinations** to cover real scenarios
|
||||
- Tests use `interval: 1s` or `TriggerManual()` for easy triggering (no time dependency)
|
||||
|
||||
### 5.1 Directory Structure
|
||||
|
||||
```
|
||||
yao-dev-app/assistants/
|
||||
├── robot/ # Generic Phase Agents
|
||||
│ ├── inspiration/ # P0: Analyze clock context, generate insights
|
||||
│ │ ├── package.yao
|
||||
│ │ └── prompts.yml
|
||||
│ ├── goals/ # P1: Generate prioritized goals
|
||||
│ │ ├── package.yao
|
||||
│ │ └── prompts.yml
|
||||
│ ├── tasks/ # P2: Split goals into executable tasks
|
||||
│ │ ├── package.yao
|
||||
│ │ └── prompts.yml
|
||||
│ ├── validation/ # P3: Validate task results
|
||||
│ │ ├── package.yao
|
||||
│ │ └── prompts.yml
|
||||
│ ├── delivery/ # P4: Format and deliver results
|
||||
│ │ ├── package.yao
|
||||
│ │ └── prompts.yml
|
||||
│ └── learning/ # P5: Summarize execution, extract insights
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
│
|
||||
└── experts/ # Expert Agents (Task Executors)
|
||||
├── text-writer/ # Generate text content (reports, emails, summaries)
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
├── web-reader/ # Fetch and parse web page content
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
├── data-analyst/ # Analyze data, generate insights
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
└── summarizer/ # Summarize long text into key points
|
||||
├── package.yao
|
||||
└── prompts.yml
|
||||
```
|
||||
|
||||
### 5.2 Generic Phase Agents
|
||||
|
||||
#### 5.2.1 Inspiration Agent (P0)
|
||||
|
||||
- [x] `robot/inspiration/package.yao` - config with model, temperature
|
||||
- [x] `robot/inspiration/prompts.yml` - system prompt:
|
||||
- Input: Clock context (time, day, markers), robot identity
|
||||
- Output: Markdown report with Summary, Highlights, Opportunities, Risks
|
||||
- Style: Analytical, context-aware
|
||||
|
||||
#### 5.2.2 Goals Agent (P1)
|
||||
|
||||
- [x] `robot/goals/package.yao` - config
|
||||
- [x] `robot/goals/prompts.yml` - system prompt:
|
||||
- Input: Inspiration report OR trigger input (human/event)
|
||||
- Output: Prioritized goals in markdown (High/Normal/Low)
|
||||
- Style: Strategic, actionable
|
||||
|
||||
#### 5.2.3 Tasks Agent (P2)
|
||||
|
||||
- [x] `robot/tasks/package.yao` - config
|
||||
- [x] `robot/tasks/prompts.yml` - system prompt:
|
||||
- Input: Goals, available expert agents list
|
||||
- Output: Structured task list (JSON) with executor assignments
|
||||
- Style: Detailed, executable
|
||||
|
||||
#### 5.2.4 Validation Agent (P3)
|
||||
|
||||
- [x] `robot/validation/package.yao` - config
|
||||
- [x] `robot/validation/prompts.yml` - system prompt:
|
||||
- Input: Task result, expected outcome
|
||||
- Output: Validation result (pass/fail, issues, suggestions)
|
||||
- Style: Critical, thorough
|
||||
|
||||
#### 5.2.5 Delivery Agent (P4)
|
||||
|
||||
- [x] `robot/delivery/package.yao` - config
|
||||
- [x] `robot/delivery/prompts.yml` - system prompt:
|
||||
- Input: Task results, delivery target (email, report, notification)
|
||||
- Output: Formatted delivery content
|
||||
- Style: Clear, professional
|
||||
|
||||
#### 5.2.6 Learning Agent (P5)
|
||||
|
||||
- [x] `robot/learning/package.yao` - config
|
||||
- [x] `robot/learning/prompts.yml` - system prompt:
|
||||
- Input: Full execution summary
|
||||
- Output: Insights, patterns, improvement suggestions
|
||||
- Style: Reflective, insightful
|
||||
|
||||
### 5.3 Expert Agents (Task Executors)
|
||||
|
||||
#### 5.3.1 Text Writer
|
||||
|
||||
- [x] `experts/text-writer/package.yao` - config
|
||||
- [x] `experts/text-writer/prompts.yml` - system prompt:
|
||||
- Input: Topic, key points, style (formal/casual), length
|
||||
- Output: Generated text content
|
||||
- Use cases: Weekly reports, email drafts, summaries
|
||||
|
||||
#### 5.3.2 Web Reader
|
||||
|
||||
- [x] `experts/web-reader/package.yao` - config with hooks
|
||||
- [x] `experts/web-reader/prompts.yml` - system prompt:
|
||||
- Input: URL or topic to search
|
||||
- Output: Extracted content, key information
|
||||
- Use cases: News fetching, competitor monitoring, research
|
||||
- [x] `experts/web-reader/src/fetch.ts` - HTTP fetching utilities
|
||||
- [x] `experts/web-reader/src/fetch_test.ts` - 19 test cases (100% pass)
|
||||
- [x] `experts/web-reader/src/index.ts` - Create/Next hooks
|
||||
|
||||
#### 5.3.3 Data Analyst
|
||||
|
||||
- [x] `experts/data-analyst/package.yao` - config
|
||||
- [x] `experts/data-analyst/prompts.yml` - system prompt:
|
||||
- Input: Data description, analysis goal
|
||||
- Output: Analysis report, trends, insights
|
||||
- Use cases: Sales analysis, performance review
|
||||
|
||||
#### 5.3.4 Summarizer
|
||||
|
||||
- [x] `experts/summarizer/package.yao` - config
|
||||
- [x] `experts/summarizer/prompts.yml` - system prompt:
|
||||
- Input: Long text content
|
||||
- Output: Concise summary with key points
|
||||
- Use cases: Document summarization, meeting notes
|
||||
|
||||
### 5.4 Test Scenarios
|
||||
|
||||
Each phase test uses different expert combinations:
|
||||
|
||||
| Test | Phase | Trigger | Expert Agents Used | Verification |
|
||||
| ---- | ----- | ---------------- | ------------------------ | ----------------------------- |
|
||||
| T1 | P0 | Clock (interval) | - | Clock → Inspiration report |
|
||||
| T2 | P1 | Clock | - | Inspiration → Goals |
|
||||
| T3 | P1 | Human | - | User input → Goals |
|
||||
| T4 | P2 | Clock | text-writer, web-reader | Goals → Tasks with executors |
|
||||
| T5 | P3 | Clock | text-writer | Task exec → Result validation |
|
||||
| T6 | P3 | Human | summarizer | Task exec → Result validation |
|
||||
| T7 | P4 | Clock | - | Results → Delivery format |
|
||||
| T8 | P5 | Clock | - | Full execution → Insights |
|
||||
| T9 | E2E | Clock | text-writer, summarizer | Full P0→P5 flow |
|
||||
| T10 | E2E | Human | web-reader, data-analyst | Full P1→P5 flow |
|
||||
|
||||
### 5.5 Verification
|
||||
|
||||
- [x] All 6 Phase Agents load correctly (`robot.inspiration`, `robot.goals`, etc.)
|
||||
- [x] All 4 Expert Agents load correctly (`experts.text-writer`, `experts.web-reader`, etc.)
|
||||
- [x] Web Reader `fetch.ts` utilities tested (19 tests, 100% pass)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: P0 Inspiration Implementation ✅
|
||||
|
||||
**Goal:** Implement P0 (Inspiration Agent). Clock trigger → P0 → stub P1-P5.
|
||||
|
||||
### 4.1 Test Assistant Setup
|
||||
**Depends on:** Phase 4 (Agent Call Infrastructure), Phase 5 (Assistants Setup)
|
||||
|
||||
Create `yao-dev-app/assistants/robot/` directory:
|
||||
**Status:** COMPLETED
|
||||
|
||||
- [ ] `inspiration/package.yao` - Inspiration Agent config
|
||||
- [ ] `inspiration/prompts.yml` - P0 prompts
|
||||
- [ ] `inspiration/src/index.ts` - hooks if needed
|
||||
### 6.1 P0 Implementation
|
||||
|
||||
### 4.2 P0 Implementation
|
||||
- [x] `executor/inspiration.go` - `RunInspiration(ctx, exec, data)` - real implementation
|
||||
- [x] `executor/inspiration.go` - build prompt using `InputFormatter.FormatClockContext()`
|
||||
- [x] `executor/inspiration.go` - call Inspiration Agent using `AgentCaller`
|
||||
- [x] `executor/inspiration.go` - parse response to `InspirationReport` (markdown content)
|
||||
- [x] `types/robot.go` - added `GetRobot()`/`SetRobot()` methods for Execution
|
||||
- [x] `executor/executor.go` - set robot reference on execution creation
|
||||
|
||||
- [ ] `executor/inspiration.go` - build prompt with `ClockContext`
|
||||
- [ ] `executor/inspiration.go` - call Inspiration Agent
|
||||
- [ ] `executor/inspiration.go` - parse response to `InspirationReport`
|
||||
- [ ] `executor/prompt.go` - `BuildInspirationPrompt()`
|
||||
### 6.2 Tests
|
||||
|
||||
### 4.3 Tests
|
||||
- [x] `executor/inspiration_test.go` - P0 with real LLM call (8 test cases)
|
||||
- [x] Test: clock context correctly formatted in prompt
|
||||
- [x] Test: robot identity included in prompt
|
||||
- [x] Test: markdown report generated with expected sections
|
||||
- [x] Test: handles LLM errors gracefully (robot nil, agent not found)
|
||||
- [x] Test: uses clock from trigger input or creates new one
|
||||
- [x] `InputFormatter.FormatClockContext()` unit tests (4 test cases)
|
||||
|
||||
- [ ] `executor/inspiration_test.go` - P0 with real LLM call
|
||||
- [ ] Verify: clock context in prompt
|
||||
- [ ] Verify: markdown report generated
|
||||
### 6.3 Notes
|
||||
|
||||
- `executor_test.go` temporarily moved to `.bak` - will restore when all phases implemented
|
||||
- P0 uses `robot.inspiration` test agent from `yao-dev-app/assistants/robot/inspiration/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Executor - P1 Goals
|
||||
## Phase 7: P1 Goals Implementation
|
||||
|
||||
**Goal:** Implement P1 (Goal Generation Agent). P0 → P1 → stub P2-P5.
|
||||
|
||||
### 5.1 Test Assistant Setup
|
||||
**Depends on:** Phase 6 (P0 Inspiration)
|
||||
|
||||
- [ ] `goals/package.yao` - Goal Generation Agent config
|
||||
- [ ] `goals/prompts.yml` - P1 prompts
|
||||
|
||||
### 5.2 P1 Implementation
|
||||
### 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 Goal Agent
|
||||
- [ ] `executor/goals.go` - parse response to `Goals` (markdown)
|
||||
- [ ] `executor/prompt.go` - `BuildGoalsPrompt()`
|
||||
- [ ] `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)
|
||||
|
||||
### 5.3 Tests
|
||||
### 7.2 Tests
|
||||
|
||||
- [ ] `executor/goals_test.go` - P1 with real LLM call
|
||||
- [ ] Verify: inspiration report in prompt
|
||||
- [ ] Verify: goals markdown generated
|
||||
- [ ] 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
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Executor - P2 Tasks
|
||||
## Phase 8: P2 Tasks Implementation
|
||||
|
||||
**Goal:** Implement P2 (Task Planning Agent). P1 → P2 → stub P3-P5.
|
||||
|
||||
### 6.1 Test Assistant Setup
|
||||
**Depends on:** Phase 7 (P1 Goals)
|
||||
|
||||
- [ ] `tasks/package.yao` - Task Planning Agent config
|
||||
- [ ] `tasks/prompts.yml` - P2 prompts
|
||||
|
||||
### 6.2 P2 Implementation
|
||||
### 8.1 P2 Implementation
|
||||
|
||||
- [ ] `executor/tasks.go` - `RunTasks(ctx, exec, data)` - real implementation
|
||||
- [ ] `executor/tasks.go` - build prompt with goals
|
||||
- [ ] `executor/tasks.go` - call Task Agent
|
||||
- [ ] `executor/tasks.go` - parse response to `[]Task` (structured)
|
||||
- [ ] `executor/prompt.go` - `BuildTasksPrompt()`
|
||||
- [ ] `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
|
||||
|
||||
### 6.3 Tests
|
||||
### 8.2 Tests
|
||||
|
||||
- [ ] `executor/tasks_test.go` - P2 with real LLM call
|
||||
- [ ] Verify: goals in prompt
|
||||
- [ ] Verify: structured tasks generated
|
||||
- [ ] 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
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Executor - P3 Run
|
||||
## Phase 9: P3 Run Implementation
|
||||
|
||||
**Goal:** Implement P3 (Task Execution). P2 → P3 → stub P4-P5.
|
||||
|
||||
### 7.1 Implementation
|
||||
**Depends on:** Phase 8 (P2 Tasks)
|
||||
|
||||
- [ ] `executor/run.go` - iterate tasks
|
||||
- [ ] `executor/run.go` - call executor (assistant/mcp/process)
|
||||
- [ ] `executor/run.go` - collect results
|
||||
- [ ] `executor/agent.go` - unified agent call method
|
||||
### 9.1 Implementation
|
||||
|
||||
### 7.2 Validation Agent Setup
|
||||
- [ ] `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
|
||||
|
||||
- [ ] `validation/package.yao` - Validation Agent config
|
||||
- [ ] `validation/prompts.yml` - validation prompts
|
||||
### 9.2 Validation Agent Setup
|
||||
|
||||
### 7.3 Tests
|
||||
- [ ] `robot/validation/package.yao` - Validation Agent config
|
||||
- [ ] `robot/validation/prompts.yml` - validation prompts
|
||||
|
||||
### 9.3 Tests
|
||||
|
||||
- [ ] `executor/run_test.go` - P3 with real agent calls
|
||||
- [ ] Verify: tasks executed in order
|
||||
- [ ] Verify: results collected
|
||||
- [ ] 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
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Executor - P4 Delivery
|
||||
## Phase 10: P4 Delivery Implementation
|
||||
|
||||
**Goal:** Implement P4 (Delivery). P3 → P4 → stub P5.
|
||||
|
||||
### 8.1 Test Assistant Setup
|
||||
**Depends on:** Phase 9 (P3 Run)
|
||||
|
||||
- [ ] `delivery/package.yao` - Delivery Agent config
|
||||
- [ ] `delivery/prompts.yml` - delivery prompts
|
||||
### 10.1 Delivery Agent Setup
|
||||
|
||||
### 8.2 Implementation
|
||||
- [ ] `robot/delivery/package.yao` - Delivery Agent config
|
||||
- [ ] `robot/delivery/prompts.yml` - delivery prompts
|
||||
|
||||
- [ ] `executor/delivery.go` - build delivery content
|
||||
- [ ] `executor/delivery.go` - send via configured channel (email/file/webhook/notify)
|
||||
### 10.2 Implementation
|
||||
|
||||
### 8.3 Tests
|
||||
- [ ] `executor/delivery.go` - `RunDelivery(ctx, exec, data)` - real implementation
|
||||
- [ ] `executor/delivery.go` - build delivery content from results
|
||||
- [ ] `executor/delivery.go` - support email delivery
|
||||
- [ ] `executor/delivery.go` - support file delivery
|
||||
- [ ] `executor/delivery.go` - support webhook delivery
|
||||
- [ ] `executor/delivery.go` - support notify delivery
|
||||
|
||||
### 10.3 Tests
|
||||
|
||||
- [ ] `executor/delivery_test.go` - P4 delivery
|
||||
- [ ] Verify: delivery sent (mock or real)
|
||||
- [ ] Test: delivery content generated correctly
|
||||
- [ ] Test: email delivery (mock or real)
|
||||
- [ ] Test: file delivery to configured path
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Executor - P5 Learning
|
||||
## Phase 11: P5 Learning Implementation
|
||||
|
||||
**Goal:** Implement P5 (Learning). Full execution flow complete.
|
||||
|
||||
### 9.1 Test Assistant Setup
|
||||
**Depends on:** Phase 10 (P4 Delivery)
|
||||
|
||||
- [ ] `learning/package.yao` - Learning Agent config
|
||||
- [ ] `learning/prompts.yml` - learning prompts
|
||||
### 11.1 Learning Agent Setup
|
||||
|
||||
### 9.2 Store Implementation
|
||||
- [ ] `robot/learning/package.yao` - Learning Agent config
|
||||
- [ ] `robot/learning/prompts.yml` - learning prompts
|
||||
|
||||
### 11.2 Store Implementation
|
||||
|
||||
- [ ] `store/store.go` - Store interface and struct
|
||||
- [ ] `store/kb.go` - KB operations (create, save, search)
|
||||
- [ ] `store/learning.go` - save learning entries to private KB
|
||||
|
||||
### 9.3 Implementation
|
||||
### 11.3 Implementation
|
||||
|
||||
- [ ] `executor/learning.go` - `RunLearning(ctx, exec, data)` - real implementation
|
||||
- [ ] `executor/learning.go` - extract learnings from execution
|
||||
- [ ] `executor/learning.go` - call Learning Agent
|
||||
- [ ] `executor/learning.go` - save to private KB
|
||||
|
||||
### 9.4 Tests
|
||||
### 11.4 Tests
|
||||
|
||||
- [ ] `executor/learning_test.go` - P5 learning
|
||||
- [ ] Verify: learnings saved to KB
|
||||
- [ ] Test: learnings extracted from execution
|
||||
- [ ] Test: learnings saved to KB
|
||||
- [ ] Test: KB can be queried for past learnings
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: API & Integration
|
||||
## Phase 12: API & Integration
|
||||
|
||||
**Goal:** Complete API implementation, end-to-end tests.
|
||||
|
||||
### 10.1 API Implementation
|
||||
### 12.1 API Implementation
|
||||
|
||||
- [ ] `api/api.go` - implement all Go API functions
|
||||
- [ ] `api/process.go` - implement all Process handlers
|
||||
- [ ] `api/jsapi.go` - implement JSAPI
|
||||
|
||||
### 10.2 End-to-End Tests
|
||||
### 12.2 End-to-End Tests
|
||||
|
||||
- [ ] Full clock trigger flow (P0 → P5)
|
||||
- [ ] Human intervention flow (P1 → P5)
|
||||
|
|
@ -534,18 +822,18 @@ Create `yao-dev-app/assistants/robot/` directory:
|
|||
- [ ] Concurrent execution test
|
||||
- [ ] Pause/Resume/Stop test
|
||||
|
||||
### 10.3 Integration with OpenAPI
|
||||
### 12.3 Integration with OpenAPI
|
||||
|
||||
- [ ] HTTP endpoints for human intervention
|
||||
- [ ] Webhook endpoints for events
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Advanced Features
|
||||
## Phase 13: Advanced Features
|
||||
|
||||
**Goal:** Implement dedup, semantic dedup, plan queue.
|
||||
|
||||
### 11.1 Fast Dedup (Time-Window)
|
||||
### 13.1 Fast Dedup (Time-Window)
|
||||
|
||||
> **Note:** Manager has `// TODO: dedup check` comment placeholder. Integrate after implementation.
|
||||
|
||||
|
|
@ -557,13 +845,13 @@ Create `yao-dev-app/assistants/robot/` directory:
|
|||
- [ ] Integrate into Manager.Tick()
|
||||
- [ ] Test: dedup check/mark, window expiry
|
||||
|
||||
### 11.2 Semantic Dedup
|
||||
### 13.2 Semantic Dedup
|
||||
|
||||
- [ ] `dedup/semantic.go` - call Dedup Agent for goal/task level dedup
|
||||
- [ ] Dedup Agent setup (`assistants/robot/dedup/`)
|
||||
- [ ] Test: semantic dedup with real LLM
|
||||
|
||||
### 11.3 Plan Queue
|
||||
### 13.3 Plan Queue
|
||||
|
||||
- [ ] `plan/plan.go` - plan queue implementation
|
||||
- [ ] Store planned tasks/goals
|
||||
|
|
@ -680,19 +968,21 @@ func TestWithLLM(t *testing.T) {
|
|||
|
||||
## Progress Tracking
|
||||
|
||||
| Phase | Status | Description |
|
||||
| --------------------- | ------ | -------------------------------------------------------------------- |
|
||||
| 1. Types & Interfaces | ✅ | All types, enums, interfaces |
|
||||
| 2. Skeleton | ✅ | Empty stubs, code compiles |
|
||||
| 3. Scheduling System | 🟡 | Cache + Pool + Trigger + Job + Executor stub ✅, Integration test 🟡 |
|
||||
| 4. P0 Inspiration | ⬜ | Inspiration Agent integration |
|
||||
| 5. P1 Goals | ⬜ | Goal Generation Agent integration |
|
||||
| 6. P2 Tasks | ⬜ | Task Planning Agent integration |
|
||||
| 7. P3 Run | ⬜ | Task execution (assistant/mcp/process) |
|
||||
| 8. P4 Delivery | ⬜ | Output delivery (email/file/webhook/notify) |
|
||||
| 9. P5 Learning | ⬜ | Learning Agent + KB save |
|
||||
| 10. API & Integration | ⬜ | Complete API, end-to-end tests |
|
||||
| 11. Advanced | ⬜ | Semantic dedup, plan queue |
|
||||
| Phase | Status | Description |
|
||||
| --------------------- | ------ | ---------------------------------------------------------------------------- |
|
||||
| 1. Types & Interfaces | ✅ | All types, enums, interfaces |
|
||||
| 2. Skeleton | ✅ | Empty stubs, code compiles |
|
||||
| 3. Scheduling System | ✅ | Cache + Pool + Trigger + Job + Executor architecture |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 13. Advanced | ⬜ | Semantic dedup, plan queue, Sandbox mode (requires container infrastructure) |
|
||||
|
||||
Legend: ⬜ Not started | 🟡 In progress | ✅ Complete
|
||||
|
||||
|
|
|
|||
148
agent/robot/executor/README.md
Normal file
148
agent/robot/executor/README.md
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# Robot Executor
|
||||
|
||||
Robot Executor provides pluggable execution strategies for robot phase execution.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
executor/
|
||||
├── types/
|
||||
│ ├── types.go # Interface definitions and common types
|
||||
│ └── helpers.go # Shared helper functions
|
||||
├── standard/
|
||||
│ ├── executor.go # Real Agent execution (production)
|
||||
│ └── phases.go # Phase implementations
|
||||
├── dryrun/
|
||||
│ └── executor.go # Simulated execution (testing/demo)
|
||||
├── sandbox/
|
||||
│ └── executor.go # Container-isolated execution (NOT IMPLEMENTED)
|
||||
└── executor.go # Factory functions and unified entry
|
||||
```
|
||||
|
||||
## Execution Modes
|
||||
|
||||
### Standard Mode (Production)
|
||||
|
||||
Real Agent calls with full phase execution:
|
||||
|
||||
```go
|
||||
exec := executor.New()
|
||||
// or
|
||||
exec := executor.NewWithConfig(executor.Config{
|
||||
OnPhaseStart: func(phase types.Phase) { ... },
|
||||
OnPhaseEnd: func(phase types.Phase) { ... },
|
||||
})
|
||||
```
|
||||
|
||||
### DryRun Mode (Testing/Demo)
|
||||
|
||||
Simulates execution without real Agent calls:
|
||||
|
||||
```go
|
||||
// Simple dry-run
|
||||
exec := executor.NewDryRun()
|
||||
|
||||
// With delay simulation
|
||||
exec := executor.NewDryRunWithDelay(100 * time.Millisecond)
|
||||
|
||||
// With full configuration
|
||||
exec := executor.NewDryRunWithConfig(executor.DryRunConfig{
|
||||
Delay: 100 * time.Millisecond,
|
||||
OnStart: func() { ... },
|
||||
OnEnd: func() { ... },
|
||||
Config: executor.Config{
|
||||
OnPhaseStart: func(phase types.Phase) { ... },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Sandbox Mode (NOT IMPLEMENTED)
|
||||
|
||||
> **⚠️ Not Implemented:** Sandbox mode requires container-level isolation (Docker/gVisor/Firecracker) for true security isolation. This is a future feature that depends on infrastructure support.
|
||||
|
||||
**Intended Design:**
|
||||
|
||||
Sandbox mode is designed for executing untrusted robot configurations in a fully isolated environment:
|
||||
|
||||
- **Container Isolation:** Each execution runs in a separate container
|
||||
- **Resource Limits:** CPU, memory, disk, network quotas enforced by container runtime
|
||||
- **Network Isolation:** Restricted network access via container networking
|
||||
- **File System Isolation:** Read-only root filesystem, limited writable paths
|
||||
- **Process Isolation:** Separate PID namespace, no access to host processes
|
||||
|
||||
**Future Implementation:**
|
||||
|
||||
```go
|
||||
// Future API (not yet implemented)
|
||||
exec := executor.NewSandbox(executor.SandboxConfig{
|
||||
Image: "yao-executor:latest",
|
||||
MaxDuration: 30 * time.Minute,
|
||||
MaxMemory: 512 * 1024 * 1024, // 512MB
|
||||
MaxCPU: 1.0, // 1 CPU core
|
||||
NetworkPolicy: "restricted", // restricted | none | full
|
||||
AllowedAgents: []string{"agent1", "agent2"},
|
||||
})
|
||||
```
|
||||
|
||||
**Current Placeholder:**
|
||||
|
||||
The current `sandbox/executor.go` is a placeholder that behaves like DryRun mode. It does NOT provide real security isolation.
|
||||
|
||||
## Mode Selection
|
||||
|
||||
Select mode dynamically:
|
||||
|
||||
```go
|
||||
// By mode constant
|
||||
exec := executor.NewWithMode(executor.ModeDryRun)
|
||||
|
||||
// From settings
|
||||
setting := &executor.Setting{
|
||||
Mode: executor.ModeStandard,
|
||||
}
|
||||
exec := executor.NewWithSetting(setting)
|
||||
```
|
||||
|
||||
## Interface
|
||||
|
||||
All executors implement the `Executor` interface:
|
||||
|
||||
```go
|
||||
type Executor interface {
|
||||
Execute(ctx *Context, robot *Robot, trigger TriggerType, data interface{}) (*Execution, error)
|
||||
ExecCount() int
|
||||
CurrentCount() int
|
||||
Reset()
|
||||
}
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
| Mode | Use Case | Status |
|
||||
| -------- | --------------------------------------------------- | ------------------ |
|
||||
| Standard | Production environment with real Agent calls | ✅ Implemented |
|
||||
| DryRun | Unit tests, integration tests, demos, previews | ✅ Implemented |
|
||||
| Sandbox | Untrusted code execution, multi-tenant environments | ⬜ Not Implemented |
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use DryRun mode by default:
|
||||
|
||||
```go
|
||||
func TestSomething(t *testing.T) {
|
||||
exec := executor.NewDryRunWithDelay(50 * time.Millisecond)
|
||||
// ... test with simulated execution
|
||||
}
|
||||
```
|
||||
|
||||
## Manager Integration
|
||||
|
||||
Inject executor into Manager:
|
||||
|
||||
```go
|
||||
exec := executor.NewDryRun()
|
||||
config := &manager.Config{
|
||||
Executor: exec,
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
```
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunDelivery executes P4: Delivery phase
|
||||
//
|
||||
// Sends execution output via configured delivery channel.
|
||||
// Supports: email, file, webhook, notify.
|
||||
//
|
||||
// Implementation (TODO Phase 8):
|
||||
// 1. Build delivery content from execution results
|
||||
// 2. Call Delivery Agent via Assistant.Stream() to format output
|
||||
// 3. Send via configured channel (email/file/webhook/notify)
|
||||
func (e *Executor) RunDelivery(_ *types.Context, exec *types.Execution, _ interface{}) error {
|
||||
// TODO (Phase 8): Replace with real delivery
|
||||
// agentID := robot.Config.Resources.GetPhaseAgent(types.PhaseDelivery)
|
||||
// messages := buildDeliveryMessages(exec.Results, robot)
|
||||
// response, err := callAgentStream(ctx, agentID, messages)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// deliveryContent := parseDeliveryContent(response)
|
||||
// err = sendDelivery(ctx, robot.Config.Delivery, deliveryContent)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// Simulate Agent Stream delay
|
||||
e.simulateStreamDelay()
|
||||
|
||||
// Generate mock delivery result
|
||||
exec.Delivery = &types.DeliveryResult{
|
||||
Type: types.DeliveryNotify,
|
||||
Success: true,
|
||||
Details: map[string]interface{}{
|
||||
"message": "Mock delivery completed successfully",
|
||||
"channel": "notify",
|
||||
"recipient": "test-user",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
201
agent/robot/executor/dryrun/executor.go
Normal file
201
agent/robot/executor/dryrun/executor.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package dryrun
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/executor/types"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Executor implements a dry-run executor that simulates execution
|
||||
// without making real Agent calls. Useful for:
|
||||
// - Testing scheduling and concurrency logic
|
||||
// - Demo and preview modes
|
||||
// - Debugging execution flow
|
||||
// - Performance testing
|
||||
type Executor struct {
|
||||
config types.DryRunConfig
|
||||
execCount atomic.Int32
|
||||
currentCount atomic.Int32
|
||||
}
|
||||
|
||||
// New creates a new dry-run executor with default settings
|
||||
func New() *Executor {
|
||||
return &Executor{}
|
||||
}
|
||||
|
||||
// NewWithDelay creates a dry-run executor with specified delay
|
||||
func NewWithDelay(delay time.Duration) *Executor {
|
||||
return &Executor{
|
||||
config: types.DryRunConfig{
|
||||
Delay: delay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithConfig creates a dry-run executor with full configuration
|
||||
func NewWithConfig(config types.DryRunConfig) *Executor {
|
||||
return &Executor{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute simulates robot execution without real Agent calls
|
||||
func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, trigger robottypes.TriggerType, data interface{}) (*robottypes.Execution, error) {
|
||||
if robot == nil {
|
||||
return nil, fmt.Errorf("robot cannot be nil")
|
||||
}
|
||||
|
||||
// Determine starting phase
|
||||
startPhaseIndex := 0
|
||||
if trigger == robottypes.TriggerHuman || trigger == robottypes.TriggerEvent {
|
||||
startPhaseIndex = 1 // Skip P0
|
||||
}
|
||||
|
||||
// Create execution record
|
||||
exec := &robottypes.Execution{
|
||||
ID: fmt.Sprintf("dryrun_%d", time.Now().UnixNano()),
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
TriggerType: trigger,
|
||||
StartTime: time.Now(),
|
||||
Status: robottypes.ExecPending,
|
||||
Phase: robottypes.AllPhases[startPhaseIndex],
|
||||
Input: types.BuildTriggerInput(trigger, data),
|
||||
}
|
||||
|
||||
// Set robot reference
|
||||
exec.SetRobot(robot)
|
||||
|
||||
// Acquire slot
|
||||
if !robot.TryAcquireSlot(exec) {
|
||||
return nil, robottypes.ErrQuotaExceeded
|
||||
}
|
||||
defer robot.RemoveExecution(exec.ID)
|
||||
|
||||
// Track counts
|
||||
e.execCount.Add(1)
|
||||
e.currentCount.Add(1)
|
||||
defer e.currentCount.Add(-1)
|
||||
|
||||
// Start callback
|
||||
if e.config.OnStart != nil {
|
||||
e.config.OnStart()
|
||||
}
|
||||
if e.config.OnEnd != nil {
|
||||
defer e.config.OnEnd()
|
||||
}
|
||||
|
||||
// Update status
|
||||
exec.Status = robottypes.ExecRunning
|
||||
|
||||
// Simulate execution delay (once for entire execution, not per-phase)
|
||||
if e.config.Delay > 0 {
|
||||
time.Sleep(e.config.Delay)
|
||||
}
|
||||
|
||||
// Check for simulated failure
|
||||
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
|
||||
exec.Status = robottypes.ExecFailed
|
||||
exec.Error = "simulated failure"
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
// Execute phases with mock data
|
||||
phases := robottypes.AllPhases[startPhaseIndex:]
|
||||
for _, phase := range phases {
|
||||
exec.Phase = phase
|
||||
|
||||
// Phase start callback
|
||||
if e.config.OnPhaseStart != nil {
|
||||
e.config.OnPhaseStart(phase)
|
||||
}
|
||||
|
||||
// Generate mock output
|
||||
e.mockPhaseOutput(exec, phase)
|
||||
|
||||
// Phase end callback
|
||||
if e.config.OnPhaseEnd != nil {
|
||||
e.config.OnPhaseEnd(phase)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark completed
|
||||
exec.Status = robottypes.ExecCompleted
|
||||
now := time.Now()
|
||||
exec.EndTime = &now
|
||||
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
// mockPhaseOutput generates mock output for each phase
|
||||
func (e *Executor) mockPhaseOutput(exec *robottypes.Execution, phase robottypes.Phase) {
|
||||
switch phase {
|
||||
case robottypes.PhaseInspiration:
|
||||
exec.Inspiration = &robottypes.InspirationReport{
|
||||
Clock: robottypes.NewClockContext(time.Now(), ""),
|
||||
Content: "## Dry-Run Inspiration\n\nThis is a simulated inspiration report for testing.",
|
||||
}
|
||||
case robottypes.PhaseGoals:
|
||||
exec.Goals = &robottypes.Goals{
|
||||
Content: "## Dry-Run Goals\n\n1. [High] Simulated goal for testing",
|
||||
}
|
||||
case robottypes.PhaseTasks:
|
||||
exec.Tasks = []robottypes.Task{
|
||||
{
|
||||
ID: "dryrun-task-1",
|
||||
GoalRef: "Goal 1",
|
||||
Source: robottypes.TaskSourceAuto,
|
||||
ExecutorType: robottypes.ExecutorAssistant,
|
||||
ExecutorID: "mock-agent",
|
||||
Status: robottypes.TaskPending,
|
||||
},
|
||||
}
|
||||
case robottypes.PhaseRun:
|
||||
exec.Results = []robottypes.TaskResult{
|
||||
{
|
||||
TaskID: "dryrun-task-1",
|
||||
Success: true,
|
||||
Output: map[string]interface{}{"mode": "dryrun", "result": "simulated"},
|
||||
Duration: 100,
|
||||
Validation: &robottypes.ValidationResult{
|
||||
Passed: true,
|
||||
Score: 1.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
case robottypes.PhaseDelivery:
|
||||
exec.Delivery = &robottypes.DeliveryResult{
|
||||
Type: robottypes.DeliveryNotify,
|
||||
Success: true,
|
||||
}
|
||||
case robottypes.PhaseLearning:
|
||||
exec.Learning = []robottypes.LearningEntry{
|
||||
{
|
||||
Type: robottypes.LearnExecution,
|
||||
Content: "Dry-run execution completed successfully",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExecCount returns total execution count
|
||||
func (e *Executor) ExecCount() int {
|
||||
return int(e.execCount.Load())
|
||||
}
|
||||
|
||||
// CurrentCount returns currently running execution count
|
||||
func (e *Executor) CurrentCount() int {
|
||||
return int(e.currentCount.Load())
|
||||
}
|
||||
|
||||
// Reset resets the executor counters
|
||||
func (e *Executor) Reset() {
|
||||
e.execCount.Store(0)
|
||||
e.currentCount.Store(0)
|
||||
}
|
||||
|
||||
// Verify Executor implements types.Executor
|
||||
var _ types.Executor = (*Executor)(nil)
|
||||
|
|
@ -1,311 +1,197 @@
|
|||
// Package executor provides robot execution strategies
|
||||
//
|
||||
// Architecture:
|
||||
//
|
||||
// executor/
|
||||
// ├── types/
|
||||
// │ ├── types.go # Interface definitions and common types
|
||||
// │ └── helpers.go # Shared helper functions
|
||||
// ├── standard/
|
||||
// │ ├── executor.go # Real Agent execution (production)
|
||||
// │ ├── agent.go # AgentCaller for LLM calls
|
||||
// │ ├── input.go # InputFormatter for prompts
|
||||
// │ ├── inspiration.go # P0: Inspiration phase
|
||||
// │ ├── goals.go # P1: Goals phase
|
||||
// │ ├── tasks.go # P2: Tasks phase
|
||||
// │ ├── run.go # P3: Run phase
|
||||
// │ ├── delivery.go # P4: Delivery phase
|
||||
// │ └── learning.go # P5: Learning phase
|
||||
// ├── dryrun/
|
||||
// │ └── executor.go # Simulated execution (testing/demo)
|
||||
// ├── sandbox/
|
||||
// │ └── executor.go # Container-isolated execution (NOT IMPLEMENTED)
|
||||
// └── executor.go # Factory functions (this file)
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// // Production - real Agent calls
|
||||
// exec := executor.New()
|
||||
//
|
||||
// // Testing - simulated execution
|
||||
// exec := executor.NewDryRun()
|
||||
//
|
||||
// // Sandbox - NOT IMPLEMENTED (requires container infrastructure)
|
||||
// // exec := executor.NewSandbox() // placeholder only
|
||||
//
|
||||
// // With mode selection
|
||||
// exec := executor.NewWithMode(executor.ModeDryRun)
|
||||
package executor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/job"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/robot/executor/dryrun"
|
||||
"github.com/yaoapp/yao/agent/robot/executor/sandbox"
|
||||
"github.com/yaoapp/yao/agent/robot/executor/standard"
|
||||
"github.com/yaoapp/yao/agent/robot/executor/types"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Config holds executor configuration
|
||||
type Config struct {
|
||||
// SkipJobIntegration skips job system integration (for unit tests)
|
||||
SkipJobIntegration bool
|
||||
// Re-export types for convenience
|
||||
type (
|
||||
Executor = types.Executor
|
||||
Config = types.Config
|
||||
DryRunConfig = types.DryRunConfig
|
||||
SandboxConfig = types.SandboxConfig
|
||||
Mode = types.Mode
|
||||
Setting = types.Setting
|
||||
)
|
||||
|
||||
// OnPhaseStart callback when a phase starts (for testing)
|
||||
OnPhaseStart func(phase types.Phase)
|
||||
// Re-export mode constants
|
||||
const (
|
||||
ModeStandard = types.ModeStandard
|
||||
ModeDryRun = types.ModeDryRun
|
||||
ModeSandbox = types.ModeSandbox
|
||||
)
|
||||
|
||||
// OnPhaseEnd callback when a phase ends (for testing)
|
||||
OnPhaseEnd func(phase types.Phase)
|
||||
// ==================== Factory Functions ====================
|
||||
|
||||
// New creates a new standard executor (production mode)
|
||||
// Uses real Agent calls for phase execution
|
||||
func New() Executor {
|
||||
return standard.New()
|
||||
}
|
||||
|
||||
// Executor implements types.Executor interface
|
||||
// This is a stub implementation that simulates full execution with Job integration
|
||||
// NewWithConfig creates a standard executor with configuration
|
||||
func NewWithConfig(config Config) Executor {
|
||||
return standard.NewWithConfig(config)
|
||||
}
|
||||
|
||||
// NewDryRun creates a dry-run executor (testing/demo mode)
|
||||
// Simulates execution without real Agent calls
|
||||
func NewDryRun() Executor {
|
||||
return dryrun.New()
|
||||
}
|
||||
|
||||
// NewDryRunWithDelay creates a dry-run executor with specified delay
|
||||
func NewDryRunWithDelay(delay time.Duration) *DryRunExecutor {
|
||||
return dryrun.NewWithDelay(delay)
|
||||
}
|
||||
|
||||
// NewDryRunWithConfig creates a dry-run executor with full configuration
|
||||
func NewDryRunWithConfig(config DryRunConfig) *DryRunExecutor {
|
||||
return dryrun.NewWithConfig(config)
|
||||
}
|
||||
|
||||
// NewDryRunWithCallbacks creates a dry-run executor with start/end callbacks
|
||||
func NewDryRunWithCallbacks(delay time.Duration, onStart, onEnd func()) *DryRunExecutor {
|
||||
return dryrun.NewWithConfig(DryRunConfig{
|
||||
Delay: delay,
|
||||
OnStart: onStart,
|
||||
OnEnd: onEnd,
|
||||
})
|
||||
}
|
||||
|
||||
// NewSandbox creates a sandbox executor placeholder
|
||||
//
|
||||
// Phase Implementation Strategy:
|
||||
// Each phase has a dedicated file and method:
|
||||
// - inspiration.go: RunInspiration() - P0
|
||||
// - goals.go: RunGoals() - P1
|
||||
// - tasks.go: RunTasks() - P2
|
||||
// - run.go: RunExecution() - P3
|
||||
// - delivery.go: RunDelivery() - P4
|
||||
// - learning.go: RunLearning() - P5
|
||||
// ⚠️ NOT IMPLEMENTED: True sandbox requires container-level isolation
|
||||
// (Docker/gVisor/Firecracker). Current implementation behaves like DryRun.
|
||||
func NewSandbox() Executor {
|
||||
return sandbox.New()
|
||||
}
|
||||
|
||||
// NewSandboxWithConfig creates a sandbox executor placeholder with configuration
|
||||
//
|
||||
// Currently all methods return mock data with simulated delay.
|
||||
// When implementing real phases (Phase 4+), replace the method body with
|
||||
// actual Agent Stream calls (Assistant.Stream()).
|
||||
type Executor struct {
|
||||
config Config
|
||||
execCount atomic.Int32 // total execution count
|
||||
currentCount atomic.Int32 // currently running count
|
||||
onStart func() // callback on execution start (for testing)
|
||||
onEnd func() // callback on execution end (for testing)
|
||||
// ⚠️ NOT IMPLEMENTED: Config options are placeholders. Current implementation
|
||||
// behaves like DryRun and does NOT provide real security isolation.
|
||||
func NewSandboxWithConfig(config SandboxConfig) Executor {
|
||||
return sandbox.NewWithConfig(config)
|
||||
}
|
||||
|
||||
// New creates a new executor instance
|
||||
func New() *Executor {
|
||||
return &Executor{}
|
||||
}
|
||||
|
||||
// NewWithConfig creates a new executor with custom configuration
|
||||
func NewWithConfig(config Config) *Executor {
|
||||
return &Executor{
|
||||
config: config,
|
||||
// NewWithMode creates an executor based on the specified mode
|
||||
func NewWithMode(mode Mode) Executor {
|
||||
switch mode {
|
||||
case ModeDryRun:
|
||||
return NewDryRun()
|
||||
case ModeSandbox:
|
||||
return NewSandbox()
|
||||
default:
|
||||
return New()
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithDelay creates a new executor with simulated delay (for testing)
|
||||
// Note: delay parameter is kept for API compatibility but not used internally
|
||||
// Real delay comes from simulateStreamDelay() which simulates Agent Stream latency
|
||||
func NewWithDelay(_ time.Duration) *Executor {
|
||||
return &Executor{
|
||||
config: Config{
|
||||
SkipJobIntegration: true, // Skip job integration for simple delay tests
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithCallback creates a new executor with callbacks (for testing concurrency)
|
||||
func NewWithCallback(_ time.Duration, onStart, onEnd func()) *Executor {
|
||||
return &Executor{
|
||||
config: Config{
|
||||
SkipJobIntegration: true, // Skip job integration for callback tests
|
||||
},
|
||||
onStart: onStart,
|
||||
onEnd: onEnd,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes a robot through all phases
|
||||
// This stub implementation:
|
||||
// 1. Creates Execution record + Job (via job package)
|
||||
// 2. Updates phase: P0 → P1 → P2 → P3 → P4 → P5
|
||||
// 3. Logs phase transitions
|
||||
// 4. Returns success with mock data
|
||||
func (e *Executor) Execute(ctx *types.Context, robot *types.Robot, trigger types.TriggerType, data interface{}) (*types.Execution, error) {
|
||||
if robot == nil {
|
||||
return nil, fmt.Errorf("robot cannot be nil")
|
||||
// NewWithSetting creates an executor based on configuration settings
|
||||
func NewWithSetting(setting *Setting) Executor {
|
||||
if setting == nil {
|
||||
return New()
|
||||
}
|
||||
|
||||
var exec *types.Execution
|
||||
var err error
|
||||
|
||||
// Determine starting phase based on trigger type
|
||||
// Clock trigger starts from P0 (Inspiration)
|
||||
// Human/Event triggers skip P0 and start from P1 (Goals)
|
||||
startPhaseIndex := 0
|
||||
if trigger == types.TriggerHuman || trigger == types.TriggerEvent {
|
||||
startPhaseIndex = 1 // Skip P0 (Inspiration)
|
||||
}
|
||||
|
||||
// Create execution with Job integration
|
||||
if !e.config.SkipJobIntegration {
|
||||
exec, err = job.CreateExecution(ctx, &job.CreateOptions{
|
||||
Robot: robot,
|
||||
TriggerType: trigger,
|
||||
Input: buildTriggerInput(trigger, data),
|
||||
switch setting.Mode {
|
||||
case ModeDryRun:
|
||||
return NewDryRun()
|
||||
case ModeSandbox:
|
||||
return NewSandboxWithConfig(SandboxConfig{
|
||||
MaxDuration: setting.MaxDuration,
|
||||
MaxMemory: setting.MaxMemory,
|
||||
AllowedAgents: setting.AllowedAgents,
|
||||
NetworkAccess: setting.NetworkAccess,
|
||||
FileAccess: setting.FileAccess,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create execution: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Simple execution for tests without job integration
|
||||
exec = &types.Execution{
|
||||
ID: fmt.Sprintf("exec_%d", time.Now().UnixNano()),
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
TriggerType: trigger,
|
||||
StartTime: time.Now(),
|
||||
Status: types.ExecPending,
|
||||
Phase: types.AllPhases[startPhaseIndex],
|
||||
Input: buildTriggerInput(trigger, data),
|
||||
}
|
||||
default:
|
||||
return New()
|
||||
}
|
||||
|
||||
// Atomically check quota and acquire slot
|
||||
// This prevents race condition where multiple workers pass CanRun() check
|
||||
// but then all add executions, exceeding the quota
|
||||
if !robot.TryAcquireSlot(exec) {
|
||||
// If job was created, mark it as failed
|
||||
if !e.config.SkipJobIntegration && exec.JobID != "" {
|
||||
_ = job.FailExecution(ctx, exec, types.ErrQuotaExceeded)
|
||||
}
|
||||
return nil, types.ErrQuotaExceeded
|
||||
}
|
||||
defer robot.RemoveExecution(exec.ID)
|
||||
|
||||
// Track execution count (after successful slot acquisition)
|
||||
e.execCount.Add(1)
|
||||
e.currentCount.Add(1)
|
||||
defer e.currentCount.Add(-1)
|
||||
|
||||
// Call start callback if set
|
||||
if e.onStart != nil {
|
||||
e.onStart()
|
||||
}
|
||||
// Call end callback on return
|
||||
if e.onEnd != nil {
|
||||
defer e.onEnd()
|
||||
}
|
||||
|
||||
// Update status to running
|
||||
exec.Status = types.ExecRunning
|
||||
if !e.config.SkipJobIntegration {
|
||||
if err := job.UpdateStatus(ctx, exec, types.ExecRunning); err != nil {
|
||||
// Log error but continue execution
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update status to running: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Check for simulated failure
|
||||
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
|
||||
exec.Status = types.ExecFailed
|
||||
exec.Error = "simulated failure"
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.FailExecution(ctx, exec, fmt.Errorf("simulated failure"))
|
||||
}
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
// Execute phases
|
||||
phases := types.AllPhases[startPhaseIndex:]
|
||||
for _, phase := range phases {
|
||||
// Run phase with common pre/post processing
|
||||
if err := e.runPhase(ctx, exec, phase, data); err != nil {
|
||||
exec.Status = types.ExecFailed
|
||||
exec.Error = err.Error()
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.FailExecution(ctx, exec, err)
|
||||
}
|
||||
return exec, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Mark execution as completed
|
||||
exec.Status = types.ExecCompleted
|
||||
now := time.Now()
|
||||
exec.EndTime = &now
|
||||
|
||||
if !e.config.SkipJobIntegration {
|
||||
if err := job.CompleteExecution(ctx, exec); err != nil {
|
||||
// Log error but return success since execution completed
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to mark execution as completed: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
// runPhase executes a single phase with common pre/post processing
|
||||
func (e *Executor) runPhase(ctx *types.Context, exec *types.Execution, phase types.Phase, data interface{}) error {
|
||||
// Update phase
|
||||
exec.Phase = phase
|
||||
// ==================== Concrete Types ====================
|
||||
// Export concrete executor types for direct access when needed
|
||||
|
||||
// Log phase start
|
||||
if !e.config.SkipJobIntegration {
|
||||
if err := job.UpdatePhase(ctx, exec, phase); err != nil {
|
||||
// Log error but continue
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update phase to %s: %v", phase, err))
|
||||
}
|
||||
}
|
||||
// DryRunExecutor is the concrete dry-run executor type
|
||||
type DryRunExecutor = dryrun.Executor
|
||||
|
||||
// Call phase start callback
|
||||
if e.config.OnPhaseStart != nil {
|
||||
e.config.OnPhaseStart(phase)
|
||||
}
|
||||
// StandardExecutor is the concrete standard executor type
|
||||
type StandardExecutor = standard.Executor
|
||||
|
||||
phaseStart := time.Now()
|
||||
// SandboxExecutor is the concrete sandbox executor type
|
||||
type SandboxExecutor = sandbox.Executor
|
||||
|
||||
// Execute phase-specific logic
|
||||
// Each phase method calls the corresponding Agent via Assistant.Stream()
|
||||
// Currently returns mock data; replace with real Agent calls in Phase 4+
|
||||
var err error
|
||||
switch phase {
|
||||
case types.PhaseInspiration:
|
||||
err = e.RunInspiration(ctx, exec, data)
|
||||
case types.PhaseGoals:
|
||||
err = e.RunGoals(ctx, exec, data)
|
||||
case types.PhaseTasks:
|
||||
err = e.RunTasks(ctx, exec, data)
|
||||
case types.PhaseRun:
|
||||
err = e.RunExecution(ctx, exec, data)
|
||||
case types.PhaseDelivery:
|
||||
err = e.RunDelivery(ctx, exec, data)
|
||||
case types.PhaseLearning:
|
||||
err = e.RunLearning(ctx, exec, data)
|
||||
}
|
||||
// ==================== Interface Verification ====================
|
||||
|
||||
if err != nil {
|
||||
// Log phase error
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.LogPhaseError(ctx, exec, phase, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Verify all executors implement the Executor interface
|
||||
var (
|
||||
_ Executor = (*standard.Executor)(nil)
|
||||
_ Executor = (*dryrun.Executor)(nil)
|
||||
_ Executor = (*sandbox.Executor)(nil)
|
||||
)
|
||||
|
||||
// Call phase end callback
|
||||
if e.config.OnPhaseEnd != nil {
|
||||
e.config.OnPhaseEnd(phase)
|
||||
}
|
||||
// Verify standard executor implements PhaseExecutor
|
||||
var _ types.PhaseExecutor = (*standard.Executor)(nil)
|
||||
|
||||
// Log phase end
|
||||
if !e.config.SkipJobIntegration {
|
||||
phaseDuration := time.Since(phaseStart).Milliseconds()
|
||||
_ = job.LogPhaseEnd(ctx, exec, phase, phaseDuration)
|
||||
}
|
||||
// ==================== Helper Types ====================
|
||||
|
||||
return nil
|
||||
// DefaultSetting returns default executor settings
|
||||
func DefaultSetting() *Setting {
|
||||
return types.DefaultSetting()
|
||||
}
|
||||
|
||||
// buildTriggerInput builds TriggerInput from trigger data
|
||||
func buildTriggerInput(trigger types.TriggerType, data interface{}) *types.TriggerInput {
|
||||
input := &types.TriggerInput{}
|
||||
// PhaseExecutor is the interface for phase execution
|
||||
type PhaseExecutor = types.PhaseExecutor
|
||||
|
||||
switch trigger {
|
||||
case types.TriggerClock:
|
||||
input.Clock = types.NewClockContext(time.Now(), "")
|
||||
// ==================== Context Helpers ====================
|
||||
|
||||
case types.TriggerHuman:
|
||||
if req, ok := data.(*types.InterveneRequest); ok {
|
||||
input.Action = req.Action
|
||||
input.Messages = req.Messages
|
||||
}
|
||||
|
||||
case types.TriggerEvent:
|
||||
if req, ok := data.(*types.EventRequest); ok {
|
||||
input.Source = types.EventSource(req.Source)
|
||||
input.EventType = req.EventType
|
||||
input.Data = req.Data
|
||||
}
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
// ExecCount returns total execution count
|
||||
func (e *Executor) ExecCount() int {
|
||||
return int(e.execCount.Load())
|
||||
}
|
||||
|
||||
// CurrentCount returns currently running execution count
|
||||
func (e *Executor) CurrentCount() int {
|
||||
return int(e.currentCount.Load())
|
||||
}
|
||||
|
||||
// Reset resets the executor counters (for testing)
|
||||
func (e *Executor) Reset() {
|
||||
e.execCount.Store(0)
|
||||
e.currentCount.Store(0)
|
||||
}
|
||||
|
||||
// DefaultStreamDelay is the simulated delay for Agent Stream calls
|
||||
// This will be removed when real Agent calls are implemented
|
||||
const DefaultStreamDelay = 50 * time.Millisecond
|
||||
|
||||
// simulateStreamDelay simulates the delay of an Agent Stream call
|
||||
// This will be removed when real Agent calls are implemented in Phase 4+
|
||||
func (e *Executor) simulateStreamDelay() {
|
||||
time.Sleep(DefaultStreamDelay)
|
||||
}
|
||||
// These are re-exported from robot types for convenience
|
||||
type (
|
||||
Context = robottypes.Context
|
||||
Robot = robottypes.Robot
|
||||
Execution = robottypes.Execution
|
||||
Phase = robottypes.Phase
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
// Real integration tests are in manager_test.go and job_test.go
|
||||
|
||||
func TestExecutorSmoke(t *testing.T) {
|
||||
exec := NewWithDelay(0)
|
||||
exec := NewDryRunWithDelay(0)
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-smoke",
|
||||
TeamID: "team-1",
|
||||
|
|
@ -38,7 +38,7 @@ func TestExecutorSmoke(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestExecutorHumanTriggerSkipsP0(t *testing.T) {
|
||||
exec := NewWithDelay(0)
|
||||
exec := NewDryRunWithDelay(0)
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-human",
|
||||
TeamID: "team-1",
|
||||
|
|
@ -58,7 +58,7 @@ func TestExecutorHumanTriggerSkipsP0(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestExecutorEventTriggerSkipsP0(t *testing.T) {
|
||||
exec := NewWithDelay(0)
|
||||
exec := NewDryRunWithDelay(0)
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-event",
|
||||
TeamID: "team-1",
|
||||
|
|
@ -74,7 +74,7 @@ func TestExecutorEventTriggerSkipsP0(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestExecutorNilRobot(t *testing.T) {
|
||||
exec := NewWithDelay(0)
|
||||
exec := NewDryRunWithDelay(0)
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
result, err := exec.Execute(ctx, nil, types.TriggerClock, nil)
|
||||
|
|
@ -85,7 +85,7 @@ func TestExecutorNilRobot(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestExecutorSimulatedFailure(t *testing.T) {
|
||||
exec := NewWithDelay(0)
|
||||
exec := NewDryRunWithDelay(0)
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-fail",
|
||||
TeamID: "team-1",
|
||||
|
|
@ -103,7 +103,7 @@ func TestExecutorSimulatedFailure(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestExecutorCounters(t *testing.T) {
|
||||
exec := NewWithDelay(0)
|
||||
exec := NewDryRunWithDelay(0)
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-counter",
|
||||
TeamID: "team-1",
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunGoals executes P1: Goals phase
|
||||
//
|
||||
// For Clock trigger: Uses InspirationReport to generate goals
|
||||
// For Human/Event: Uses TriggerInput directly as goals or to generate goals
|
||||
//
|
||||
// Implementation (TODO Phase 5):
|
||||
// 1. Build prompt with InspirationReport (or TriggerInput for Human/Event)
|
||||
// 2. Call Goal Generation Agent via Assistant.Stream()
|
||||
// 3. Parse response to Goals (markdown)
|
||||
func (e *Executor) RunGoals(_ *types.Context, exec *types.Execution, _ interface{}) error {
|
||||
// TODO (Phase 5): Replace with real Agent call
|
||||
// agentID := robot.Config.Resources.GetPhaseAgent(types.PhaseGoals)
|
||||
// messages := buildGoalsMessages(exec.Inspiration, exec.Input, robot)
|
||||
// response, err := callAgentStream(ctx, agentID, messages)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// exec.Goals = parseGoals(response)
|
||||
|
||||
// Simulate Agent Stream delay
|
||||
e.simulateStreamDelay()
|
||||
|
||||
// Generate mock goals
|
||||
exec.Goals = &types.Goals{
|
||||
Content: `## Goals
|
||||
|
||||
1. [High] Complete primary objective
|
||||
- Reason: Critical for business success
|
||||
- Expected outcome: Measurable improvement
|
||||
|
||||
2. [Normal] Review and validate results
|
||||
- Reason: Quality assurance required
|
||||
- Expected outcome: Verified deliverables
|
||||
|
||||
3. [Low] Document learnings
|
||||
- Reason: Future reference and improvement
|
||||
- Expected outcome: Knowledge base update`,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunInspiration executes P0: Inspiration phase (Clock trigger only)
|
||||
//
|
||||
// This phase gathers information to help make good goals.
|
||||
// ClockContext is the key input - Agent knows what time it is and can decide
|
||||
// what to do (e.g., 5pm Friday → write weekly report).
|
||||
//
|
||||
// Implementation (TODO Phase 4):
|
||||
// 1. Build prompt with ClockContext + data sources (KB, DB, web search)
|
||||
// 2. Call Inspiration Agent via Assistant.Stream()
|
||||
// 3. Parse response to InspirationReport (markdown)
|
||||
func (e *Executor) RunInspiration(_ *types.Context, exec *types.Execution, _ interface{}) error {
|
||||
// TODO (Phase 4): Replace with real Agent call
|
||||
// agentID := robot.Config.Resources.GetPhaseAgent(types.PhaseInspiration)
|
||||
// messages := buildInspirationMessages(exec.Input.Clock, robot)
|
||||
// response, err := callAgentStream(ctx, agentID, messages)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// exec.Inspiration = parseInspirationReport(response)
|
||||
|
||||
// Simulate Agent Stream delay
|
||||
e.simulateStreamDelay()
|
||||
|
||||
// Generate mock inspiration report
|
||||
exec.Inspiration = &types.InspirationReport{
|
||||
Clock: types.NewClockContext(time.Now(), ""),
|
||||
Content: `## Summary
|
||||
Mock inspiration report for testing.
|
||||
|
||||
## Highlights
|
||||
- [High] Test item 1 - Critical business metric changed
|
||||
- [Normal] Test item 2 - Regular update available
|
||||
|
||||
## Opportunities
|
||||
- Market growth potential identified
|
||||
- New customer segment emerging
|
||||
|
||||
## Risks
|
||||
- None identified in current period
|
||||
|
||||
## Pending
|
||||
- 2 tasks from previous execution
|
||||
- 1 scheduled report due`,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunLearning executes P5: Learning phase
|
||||
//
|
||||
// Extracts learnings from execution and saves to private KB.
|
||||
// Learning types: execution (what worked), feedback (errors), insight (patterns).
|
||||
//
|
||||
// Implementation (TODO Phase 9):
|
||||
// 1. Build prompt with execution summary
|
||||
// 2. Call Learning Agent via Assistant.Stream() to extract learnings
|
||||
// 3. Save learning entries to private KB
|
||||
func (e *Executor) RunLearning(_ *types.Context, exec *types.Execution, _ interface{}) error {
|
||||
// TODO (Phase 9): Replace with real learning
|
||||
// agentID := robot.Config.Resources.GetPhaseAgent(types.PhaseLearning)
|
||||
// messages := buildLearningMessages(exec, robot)
|
||||
// response, err := callAgentStream(ctx, agentID, messages)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// exec.Learning = parseLearningEntries(response)
|
||||
// err = saveLearningToKB(ctx, robot, exec.Learning)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// Simulate Agent Stream delay
|
||||
e.simulateStreamDelay()
|
||||
|
||||
// Generate mock learning entries
|
||||
exec.Learning = []types.LearningEntry{
|
||||
{
|
||||
Type: types.LearnExecution,
|
||||
Content: "Execution completed successfully with all tasks passing. Total duration within expected range.",
|
||||
Tags: []string{"success", "performance"},
|
||||
},
|
||||
{
|
||||
Type: types.LearnInsight,
|
||||
Content: "Task execution order optimization: Running data analysis before report generation improves efficiency.",
|
||||
Tags: []string{"optimization", "workflow"},
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunExecution executes P3: Run phase
|
||||
//
|
||||
// Iterates through tasks and executes each one using the specified executor.
|
||||
// Supports three executor types: assistant, mcp, process.
|
||||
//
|
||||
// Implementation (TODO Phase 7):
|
||||
// 1. Iterate tasks
|
||||
// 2. For each task, call executor (assistant/mcp/process)
|
||||
// 3. Validate results
|
||||
// 4. Collect results
|
||||
func (e *Executor) RunExecution(_ *types.Context, exec *types.Execution, _ interface{}) error {
|
||||
// TODO (Phase 7): Replace with real task execution
|
||||
// for i, task := range exec.Tasks {
|
||||
// exec.Current = &types.CurrentState{Task: &task, TaskIndex: i}
|
||||
// result, err := executeTask(ctx, task, robot)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// exec.Results = append(exec.Results, result)
|
||||
// }
|
||||
|
||||
// Handle empty tasks case
|
||||
if len(exec.Tasks) == 0 {
|
||||
exec.Current = &types.CurrentState{
|
||||
TaskIndex: 0,
|
||||
Progress: "0/0 tasks",
|
||||
}
|
||||
exec.Results = []types.TaskResult{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set current state (will be updated as tasks complete)
|
||||
exec.Current = &types.CurrentState{
|
||||
TaskIndex: 0,
|
||||
Progress: fmt.Sprintf("0/%d tasks", len(exec.Tasks)),
|
||||
}
|
||||
|
||||
// Simulate execution of each task
|
||||
exec.Results = make([]types.TaskResult, len(exec.Tasks))
|
||||
for i := range exec.Tasks {
|
||||
// Update current state
|
||||
exec.Current.TaskIndex = i
|
||||
exec.Current.Task = &exec.Tasks[i]
|
||||
exec.Current.Progress = fmt.Sprintf("%d/%d tasks", i+1, len(exec.Tasks))
|
||||
|
||||
// Mark task start time
|
||||
startTime := time.Now()
|
||||
exec.Tasks[i].StartTime = &startTime
|
||||
|
||||
// Simulate Agent Stream delay for each task
|
||||
e.simulateStreamDelay()
|
||||
|
||||
// Mark task as completed
|
||||
exec.Tasks[i].Status = types.TaskCompleted
|
||||
endTime := time.Now()
|
||||
exec.Tasks[i].EndTime = &endTime
|
||||
|
||||
// Generate mock result with actual duration
|
||||
exec.Results[i] = types.TaskResult{
|
||||
TaskID: exec.Tasks[i].ID,
|
||||
Success: true,
|
||||
Output: fmt.Sprintf("Mock output for %s: Task completed successfully", exec.Tasks[i].ID),
|
||||
Duration: endTime.Sub(startTime).Milliseconds(),
|
||||
Validated: true,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
234
agent/robot/executor/sandbox/executor.go
Normal file
234
agent/robot/executor/sandbox/executor.go
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/executor/types"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Executor implements a sandboxed executor placeholder.
|
||||
//
|
||||
// ⚠️ NOT IMPLEMENTED: True sandbox mode requires container-level isolation
|
||||
// (Docker/gVisor/Firecracker) for security. This placeholder currently
|
||||
// behaves like DryRun mode and does NOT provide real security isolation.
|
||||
//
|
||||
// Future Implementation:
|
||||
// - Container isolation: Each execution in separate container
|
||||
// - Resource limits: CPU, memory, disk enforced by container runtime
|
||||
// - Network isolation: Restricted network via container networking
|
||||
// - File system isolation: Read-only root, limited writable paths
|
||||
// - Process isolation: Separate PID namespace
|
||||
//
|
||||
// Current behavior: Simulates execution with mock data (same as DryRun)
|
||||
type Executor struct {
|
||||
config types.SandboxConfig
|
||||
execCount atomic.Int32
|
||||
currentCount atomic.Int32
|
||||
}
|
||||
|
||||
// New creates a new sandbox executor with default settings
|
||||
func New() *Executor {
|
||||
return &Executor{
|
||||
config: types.SandboxConfig{
|
||||
MaxDuration: 30 * time.Minute,
|
||||
NetworkAccess: true,
|
||||
FileAccess: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithConfig creates a sandbox executor with custom configuration
|
||||
func NewWithConfig(config types.SandboxConfig) *Executor {
|
||||
return &Executor{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute runs robot execution within sandbox constraints
|
||||
func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, trigger robottypes.TriggerType, data interface{}) (*robottypes.Execution, error) {
|
||||
if robot == nil {
|
||||
return nil, fmt.Errorf("robot cannot be nil")
|
||||
}
|
||||
|
||||
// Create timeout context
|
||||
execCtx, cancel := context.WithTimeout(ctx.Context, e.config.MaxDuration)
|
||||
defer cancel()
|
||||
|
||||
// Create new context with timeout
|
||||
sandboxCtx := robottypes.NewContext(execCtx, ctx.Auth)
|
||||
|
||||
// Determine starting phase
|
||||
startPhaseIndex := 0
|
||||
if trigger == robottypes.TriggerHuman || trigger == robottypes.TriggerEvent {
|
||||
startPhaseIndex = 1
|
||||
}
|
||||
|
||||
// Create execution record
|
||||
exec := &robottypes.Execution{
|
||||
ID: fmt.Sprintf("sandbox_%d", time.Now().UnixNano()),
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
TriggerType: trigger,
|
||||
StartTime: time.Now(),
|
||||
Status: robottypes.ExecPending,
|
||||
Phase: robottypes.AllPhases[startPhaseIndex],
|
||||
Input: types.BuildTriggerInput(trigger, data),
|
||||
}
|
||||
|
||||
// Set robot reference
|
||||
exec.SetRobot(robot)
|
||||
|
||||
// Acquire slot
|
||||
if !robot.TryAcquireSlot(exec) {
|
||||
return nil, robottypes.ErrQuotaExceeded
|
||||
}
|
||||
defer robot.RemoveExecution(exec.ID)
|
||||
|
||||
// Track counts
|
||||
e.execCount.Add(1)
|
||||
e.currentCount.Add(1)
|
||||
defer e.currentCount.Add(-1)
|
||||
|
||||
// Update status
|
||||
exec.Status = robottypes.ExecRunning
|
||||
|
||||
// Execute phases with sandbox constraints
|
||||
phases := robottypes.AllPhases[startPhaseIndex:]
|
||||
for _, phase := range phases {
|
||||
// Check timeout
|
||||
select {
|
||||
case <-execCtx.Done():
|
||||
exec.Status = robottypes.ExecFailed
|
||||
exec.Error = "execution timeout exceeded"
|
||||
return exec, nil
|
||||
default:
|
||||
}
|
||||
|
||||
exec.Phase = phase
|
||||
|
||||
if e.config.OnPhaseStart != nil {
|
||||
e.config.OnPhaseStart(phase)
|
||||
}
|
||||
|
||||
// Execute phase with sandbox constraints
|
||||
if err := e.runSandboxedPhase(sandboxCtx, exec, phase, data); err != nil {
|
||||
exec.Status = robottypes.ExecFailed
|
||||
exec.Error = err.Error()
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
if e.config.OnPhaseEnd != nil {
|
||||
e.config.OnPhaseEnd(phase)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark completed
|
||||
exec.Status = robottypes.ExecCompleted
|
||||
now := time.Now()
|
||||
exec.EndTime = &now
|
||||
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
// runSandboxedPhase executes a phase with sandbox constraints
|
||||
func (e *Executor) runSandboxedPhase(ctx *robottypes.Context, exec *robottypes.Execution, phase robottypes.Phase, data interface{}) error {
|
||||
// Validate agent is allowed (if whitelist is set)
|
||||
if len(e.config.AllowedAgents) > 0 {
|
||||
robot := exec.GetRobot()
|
||||
if robot != nil && robot.Config != nil && robot.Config.Resources != nil {
|
||||
agentID := robot.Config.Resources.GetPhaseAgent(phase)
|
||||
if !e.isAgentAllowed(agentID) {
|
||||
return fmt.Errorf("agent %s is not allowed in sandbox", agentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For now, generate mock output (real implementation would call agents with restrictions)
|
||||
e.mockPhaseOutput(exec, phase)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAgentAllowed checks if an agent is in the whitelist
|
||||
func (e *Executor) isAgentAllowed(agentID string) bool {
|
||||
for _, allowed := range e.config.AllowedAgents {
|
||||
if allowed == agentID || allowed == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mockPhaseOutput generates mock output for each phase
|
||||
func (e *Executor) mockPhaseOutput(exec *robottypes.Execution, phase robottypes.Phase) {
|
||||
switch phase {
|
||||
case robottypes.PhaseInspiration:
|
||||
exec.Inspiration = &robottypes.InspirationReport{
|
||||
Clock: robottypes.NewClockContext(time.Now(), ""),
|
||||
Content: "## Sandbox Inspiration\n\nExecuted in isolated sandbox environment.",
|
||||
}
|
||||
case robottypes.PhaseGoals:
|
||||
exec.Goals = &robottypes.Goals{
|
||||
Content: "## Sandbox Goals\n\n1. [High] Sandboxed goal execution",
|
||||
}
|
||||
case robottypes.PhaseTasks:
|
||||
exec.Tasks = []robottypes.Task{
|
||||
{
|
||||
ID: "sandbox-task-1",
|
||||
GoalRef: "Goal 1",
|
||||
Source: robottypes.TaskSourceAuto,
|
||||
ExecutorType: robottypes.ExecutorAssistant,
|
||||
ExecutorID: "sandbox-agent",
|
||||
Status: robottypes.TaskPending,
|
||||
},
|
||||
}
|
||||
case robottypes.PhaseRun:
|
||||
exec.Results = []robottypes.TaskResult{
|
||||
{
|
||||
TaskID: "sandbox-task-1",
|
||||
Success: true,
|
||||
Output: map[string]interface{}{"mode": "sandbox", "isolated": true},
|
||||
Duration: 50,
|
||||
Validation: &robottypes.ValidationResult{
|
||||
Passed: true,
|
||||
Score: 1.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
case robottypes.PhaseDelivery:
|
||||
exec.Delivery = &robottypes.DeliveryResult{
|
||||
Type: robottypes.DeliveryNotify,
|
||||
Success: true,
|
||||
}
|
||||
case robottypes.PhaseLearning:
|
||||
exec.Learning = []robottypes.LearningEntry{
|
||||
{
|
||||
Type: robottypes.LearnExecution,
|
||||
Content: "Sandbox execution completed within constraints",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExecCount returns total execution count
|
||||
func (e *Executor) ExecCount() int {
|
||||
return int(e.execCount.Load())
|
||||
}
|
||||
|
||||
// CurrentCount returns currently running execution count
|
||||
func (e *Executor) CurrentCount() int {
|
||||
return int(e.currentCount.Load())
|
||||
}
|
||||
|
||||
// Reset resets the executor counters
|
||||
func (e *Executor) Reset() {
|
||||
e.execCount.Store(0)
|
||||
e.currentCount.Store(0)
|
||||
}
|
||||
|
||||
// Verify Executor implements types.Executor
|
||||
var _ types.Executor = (*Executor)(nil)
|
||||
475
agent/robot/executor/standard/agent.go
Normal file
475
agent/robot/executor/standard/agent.go
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/text"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// AgentCaller provides unified interface for calling AI assistants
|
||||
// It wraps the Yao Assistant framework and handles:
|
||||
// - Getting assistant by ID
|
||||
// - Single call with messages (streaming)
|
||||
// - Multi-turn conversation with session state
|
||||
// - Parsing responses (text, JSON, Next hook data)
|
||||
type AgentCaller struct {
|
||||
// SkipOutput skips sending output to client (for internal calls)
|
||||
SkipOutput bool
|
||||
|
||||
// SkipHistory skips saving to chat history (default: true for robot)
|
||||
// Set to false to enable multi-turn conversation with history
|
||||
SkipHistory bool
|
||||
|
||||
// SkipSearch skips auto search
|
||||
SkipSearch bool
|
||||
|
||||
// ChatID is used for multi-turn conversations to maintain session state
|
||||
// If empty, each call is independent (no history)
|
||||
ChatID string
|
||||
}
|
||||
|
||||
// NewAgentCaller creates a new AgentCaller with default settings (single-call mode)
|
||||
func NewAgentCaller() *AgentCaller {
|
||||
return &AgentCaller{
|
||||
SkipOutput: true, // Robot executions don't send to UI
|
||||
SkipHistory: true, // Robot executions don't save to chat history
|
||||
SkipSearch: true, // Robot executions don't trigger auto search
|
||||
}
|
||||
}
|
||||
|
||||
// NewConversationCaller creates an AgentCaller for multi-turn conversations
|
||||
// chatID is used to maintain session state across calls
|
||||
// This is useful for:
|
||||
// - P2 (Tasks): Iterative task refinement with user feedback
|
||||
// - P3 (Run): Multi-step task execution with intermediate results
|
||||
func NewConversationCaller(chatID string) *AgentCaller {
|
||||
return &AgentCaller{
|
||||
SkipOutput: true,
|
||||
SkipHistory: false, // Enable history for multi-turn
|
||||
SkipSearch: true,
|
||||
ChatID: chatID,
|
||||
}
|
||||
}
|
||||
|
||||
// CallResult holds the result of an agent call
|
||||
type CallResult struct {
|
||||
// Content is the raw text content from LLM completion
|
||||
Content string
|
||||
|
||||
// Next is the data returned from Next hook (if any)
|
||||
// This is typically a structured response from the assistant
|
||||
Next interface{}
|
||||
|
||||
// Response is the full response object (for advanced use)
|
||||
Response *agentcontext.Response
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the result has no content
|
||||
func (r *CallResult) IsEmpty() bool {
|
||||
return r.Content == "" && r.Next == nil
|
||||
}
|
||||
|
||||
// GetText returns the text content, preferring Content over Next
|
||||
func (r *CallResult) GetText() string {
|
||||
if r.Content != "" {
|
||||
return r.Content
|
||||
}
|
||||
// If Next is a string, return it
|
||||
if s, ok := r.Next.(string); ok {
|
||||
return s
|
||||
}
|
||||
// If Next has a "content" field, return it
|
||||
if m, ok := r.Next.(map[string]interface{}); ok {
|
||||
if content, ok := m["content"].(string); ok {
|
||||
return content
|
||||
}
|
||||
// Also check "data" field (common pattern in Next hook)
|
||||
if data, ok := m["data"].(map[string]interface{}); ok {
|
||||
if content, ok := data["content"].(string); ok {
|
||||
return content
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetJSON attempts to parse the result as JSON
|
||||
// It tries in order:
|
||||
// 1. Next hook data (already structured)
|
||||
// 2. Content parsed using gou/text.ExtractJSON (fault-tolerant)
|
||||
// Returns the parsed data and any error
|
||||
func (r *CallResult) GetJSON() (map[string]interface{}, error) {
|
||||
// Try Next hook data first
|
||||
if r.Next != nil {
|
||||
if m, ok := r.Next.(map[string]interface{}); ok {
|
||||
// Check for "data" wrapper (common in Next hook)
|
||||
if data, ok := m["data"].(map[string]interface{}); ok {
|
||||
return data, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try parsing Content using gou/text (handles markdown blocks, JSON, YAML)
|
||||
if r.Content != "" {
|
||||
data := text.ExtractJSON(r.Content)
|
||||
if data != nil {
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("content is not a JSON object")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no content to parse")
|
||||
}
|
||||
|
||||
// GetJSONArray attempts to parse the result as JSON array
|
||||
// Similar to GetJSON but for array responses
|
||||
func (r *CallResult) GetJSONArray() ([]interface{}, error) {
|
||||
// Try Next hook data first
|
||||
if r.Next != nil {
|
||||
if arr, ok := r.Next.([]interface{}); ok {
|
||||
return arr, nil
|
||||
}
|
||||
if m, ok := r.Next.(map[string]interface{}); ok {
|
||||
// Check for "data" wrapper
|
||||
if data, ok := m["data"].([]interface{}); ok {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try parsing Content using gou/text (handles markdown blocks, JSON, YAML)
|
||||
if r.Content != "" {
|
||||
data := text.ExtractJSON(r.Content)
|
||||
if data != nil {
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
return arr, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("content is not a JSON array")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no content to parse")
|
||||
}
|
||||
|
||||
// Call calls an assistant with messages and returns the result
|
||||
// This is the main entry point for agent calls
|
||||
func (c *AgentCaller) Call(ctx *robottypes.Context, assistantID string, messages []agentcontext.Message) (*CallResult, error) {
|
||||
// Get assistant
|
||||
ast, err := assistant.Get(assistantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assistant not found: %s: %w", assistantID, err)
|
||||
}
|
||||
|
||||
// Build options
|
||||
opts := &agentcontext.Options{
|
||||
Skip: &agentcontext.Skip{
|
||||
Output: c.SkipOutput,
|
||||
History: c.SkipHistory,
|
||||
Search: c.SkipSearch,
|
||||
},
|
||||
}
|
||||
|
||||
// Convert robot context to agent context
|
||||
agentCtx := c.buildAgentContext(ctx)
|
||||
|
||||
// Call assistant with streaming
|
||||
response, err := ast.Stream(agentCtx, messages, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assistant call failed: %w", err)
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := &CallResult{
|
||||
Response: response,
|
||||
}
|
||||
|
||||
// Extract Next hook data
|
||||
if response.Next != nil {
|
||||
result.Next = response.Next
|
||||
}
|
||||
|
||||
// Extract Content from Completion
|
||||
if response.Completion != nil {
|
||||
if content, ok := response.Completion.Content.(string); ok {
|
||||
result.Content = content
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CallWithMessages is a convenience method that builds messages from a single user input
|
||||
func (c *AgentCaller) CallWithMessages(ctx *robottypes.Context, assistantID string, userContent string) (*CallResult, error) {
|
||||
messages := []agentcontext.Message{
|
||||
{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: userContent,
|
||||
},
|
||||
}
|
||||
return c.Call(ctx, assistantID, messages)
|
||||
}
|
||||
|
||||
// CallWithSystemAndUser calls with both system and user messages
|
||||
func (c *AgentCaller) CallWithSystemAndUser(ctx *robottypes.Context, assistantID string, systemContent, userContent string) (*CallResult, error) {
|
||||
messages := []agentcontext.Message{
|
||||
{
|
||||
Role: agentcontext.RoleSystem,
|
||||
Content: systemContent,
|
||||
},
|
||||
{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: userContent,
|
||||
},
|
||||
}
|
||||
return c.Call(ctx, assistantID, messages)
|
||||
}
|
||||
|
||||
// buildAgentContext converts robot context to agent context
|
||||
func (c *AgentCaller) buildAgentContext(ctx *robottypes.Context) *agentcontext.Context {
|
||||
// Build authorized info for agent context
|
||||
var authorized *oauthtypes.AuthorizedInfo
|
||||
if ctx.Auth != nil {
|
||||
authorized = &oauthtypes.AuthorizedInfo{
|
||||
UserID: ctx.Auth.UserID,
|
||||
TeamID: ctx.Auth.TeamID,
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new agent context
|
||||
// Use ChatID for multi-turn conversations, empty for single calls
|
||||
agentCtx := agentcontext.New(ctx.Context, authorized, c.ChatID)
|
||||
|
||||
// Set locale if available
|
||||
if ctx.Locale != "" {
|
||||
agentCtx.Locale = ctx.Locale
|
||||
}
|
||||
|
||||
// Use noop logger to suppress LLM debug output for robot executions
|
||||
// Robot executions run in background and don't need console output
|
||||
if agentCtx.Logger != nil {
|
||||
agentCtx.Logger.Close()
|
||||
}
|
||||
agentCtx.Logger = agentcontext.Noop()
|
||||
|
||||
return agentCtx
|
||||
}
|
||||
|
||||
// ExtractCodeBlock extracts the first code block from content using gou/text
|
||||
// Returns the CodeBlock with type, content, and parsed data (for JSON/YAML)
|
||||
func ExtractCodeBlock(content string) *text.CodeBlock {
|
||||
return text.ExtractFirst(content)
|
||||
}
|
||||
|
||||
// ExtractAllCodeBlocks extracts all code blocks from content using gou/text
|
||||
func ExtractAllCodeBlocks(content string) []text.CodeBlock {
|
||||
return text.Extract(content)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversation - Multi-turn dialogue support
|
||||
// ============================================================================
|
||||
|
||||
// Conversation manages a multi-turn dialogue with an assistant
|
||||
// Useful for:
|
||||
// - P2 (Tasks): Iterative task planning with clarification
|
||||
// - P3 (Run): Multi-step execution with intermediate validation
|
||||
// - Complex reasoning that requires back-and-forth
|
||||
type Conversation struct {
|
||||
caller *AgentCaller
|
||||
assistantID string
|
||||
messages []agentcontext.Message
|
||||
maxTurns int
|
||||
}
|
||||
|
||||
// TurnResult holds the result of a single conversation turn
|
||||
type TurnResult struct {
|
||||
Turn int // Turn number (1-based)
|
||||
Input string // User input for this turn
|
||||
Result *CallResult // Agent response
|
||||
Messages []agentcontext.Message // Full message history after this turn
|
||||
}
|
||||
|
||||
// NewConversation creates a new multi-turn conversation
|
||||
// assistantID: the assistant to converse with
|
||||
// chatID: session ID for maintaining state (use exec.ID for robot executions)
|
||||
// maxTurns: maximum number of turns (0 = unlimited)
|
||||
func NewConversation(assistantID, chatID string, maxTurns int) *Conversation {
|
||||
return &Conversation{
|
||||
caller: NewConversationCaller(chatID),
|
||||
assistantID: assistantID,
|
||||
messages: make([]agentcontext.Message, 0),
|
||||
maxTurns: maxTurns,
|
||||
}
|
||||
}
|
||||
|
||||
// WithCaller sets a custom AgentCaller for the conversation
|
||||
// Useful for customizing SkipSearch or other options
|
||||
func (c *Conversation) WithCaller(caller *AgentCaller) *Conversation {
|
||||
c.caller = caller
|
||||
return c
|
||||
}
|
||||
|
||||
// WithSystemPrompt adds a system prompt at the beginning of the conversation
|
||||
func (c *Conversation) WithSystemPrompt(systemPrompt string) *Conversation {
|
||||
if systemPrompt != "" {
|
||||
c.messages = append([]agentcontext.Message{{
|
||||
Role: agentcontext.RoleSystem,
|
||||
Content: systemPrompt,
|
||||
}}, c.messages...)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// WithHistory initializes the conversation with existing message history
|
||||
// Note: Message structs are copied, but Content (interface{}) is a shallow copy
|
||||
func (c *Conversation) WithHistory(messages []agentcontext.Message) *Conversation {
|
||||
c.messages = append(c.messages, messages...)
|
||||
return c
|
||||
}
|
||||
|
||||
// Turn executes a single turn in the conversation
|
||||
// userInput: the user's message for this turn
|
||||
// Returns the turn result with agent response
|
||||
func (c *Conversation) Turn(ctx *robottypes.Context, userInput string) (*TurnResult, error) {
|
||||
// Check max turns
|
||||
turnNum := c.TurnCount() + 1
|
||||
if c.maxTurns > 0 && turnNum > c.maxTurns {
|
||||
return nil, fmt.Errorf("max turns (%d) exceeded", c.maxTurns)
|
||||
}
|
||||
|
||||
// Build messages with user input (don't modify history yet)
|
||||
userMsg := agentcontext.Message{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: userInput,
|
||||
}
|
||||
// Create a new slice to avoid modifying c.messages if capacity allows append in-place
|
||||
messagesWithInput := make([]agentcontext.Message, len(c.messages)+1)
|
||||
copy(messagesWithInput, c.messages)
|
||||
messagesWithInput[len(c.messages)] = userMsg
|
||||
|
||||
// Call assistant with full history
|
||||
result, err := c.caller.Call(ctx, c.assistantID, messagesWithInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("turn %d failed: %w", turnNum, err)
|
||||
}
|
||||
|
||||
// Only update history after successful call
|
||||
c.messages = append(c.messages, userMsg)
|
||||
|
||||
// Add assistant response to history
|
||||
if result.Content != "" {
|
||||
c.messages = append(c.messages, agentcontext.Message{
|
||||
Role: agentcontext.RoleAssistant,
|
||||
Content: result.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Return a copy of messages to prevent external modification
|
||||
messagesCopy := make([]agentcontext.Message, len(c.messages))
|
||||
copy(messagesCopy, c.messages)
|
||||
|
||||
return &TurnResult{
|
||||
Turn: turnNum,
|
||||
Input: userInput,
|
||||
Result: result,
|
||||
Messages: messagesCopy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TurnCount returns the number of user turns so far
|
||||
func (c *Conversation) TurnCount() int {
|
||||
count := 0
|
||||
for _, msg := range c.messages {
|
||||
if msg.Role == agentcontext.RoleUser {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Messages returns a copy of the current message history
|
||||
func (c *Conversation) Messages() []agentcontext.Message {
|
||||
messagesCopy := make([]agentcontext.Message, len(c.messages))
|
||||
copy(messagesCopy, c.messages)
|
||||
return messagesCopy
|
||||
}
|
||||
|
||||
// LastResponse returns a copy of the last assistant response, or nil if none
|
||||
func (c *Conversation) LastResponse() *agentcontext.Message {
|
||||
for i := len(c.messages) - 1; i >= 0; i-- {
|
||||
if c.messages[i].Role == agentcontext.RoleAssistant {
|
||||
// Return a copy to prevent external modification
|
||||
msg := c.messages[i]
|
||||
return &msg
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reset clears the conversation history (keeps system prompt if any)
|
||||
func (c *Conversation) Reset() {
|
||||
// Keep system prompt if present
|
||||
var systemPrompt *agentcontext.Message
|
||||
if len(c.messages) > 0 && c.messages[0].Role == agentcontext.RoleSystem {
|
||||
systemPrompt = &c.messages[0]
|
||||
}
|
||||
|
||||
c.messages = make([]agentcontext.Message, 0)
|
||||
if systemPrompt != nil {
|
||||
c.messages = append(c.messages, *systemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
// RunUntil runs the conversation until a condition is met
|
||||
// checkFn: called after each turn, returns (done, error)
|
||||
// Returns all turn results
|
||||
func (c *Conversation) RunUntil(
|
||||
ctx *robottypes.Context,
|
||||
inputFn func(turn int, lastResult *CallResult) (string, error),
|
||||
checkFn func(turn int, result *CallResult) (done bool, err error),
|
||||
) ([]*TurnResult, error) {
|
||||
var results []*TurnResult
|
||||
|
||||
for {
|
||||
turnNum := c.TurnCount() + 1
|
||||
|
||||
// Check max turns
|
||||
if c.maxTurns > 0 && turnNum > c.maxTurns {
|
||||
return results, fmt.Errorf("max turns (%d) exceeded without completion", c.maxTurns)
|
||||
}
|
||||
|
||||
// Get input for this turn
|
||||
var lastResult *CallResult
|
||||
if len(results) > 0 {
|
||||
lastResult = results[len(results)-1].Result
|
||||
}
|
||||
|
||||
input, err := inputFn(turnNum, lastResult)
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("input generation failed at turn %d: %w", turnNum, err)
|
||||
}
|
||||
|
||||
// Execute turn
|
||||
turnResult, err := c.Turn(ctx, input)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
results = append(results, turnResult)
|
||||
|
||||
// Check completion condition
|
||||
done, err := checkFn(turnNum, turnResult.Result)
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("check failed at turn %d: %w", turnNum, err)
|
||||
}
|
||||
if done {
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
585
agent/robot/executor/standard/agent_test.go
Normal file
585
agent/robot/executor/standard/agent_test.go
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
package standard_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/robot/executor/standard"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// testAuth returns a test auth info for agent calls
|
||||
func testAuth() *oauthtypes.AuthorizedInfo {
|
||||
return &oauthtypes.AuthorizedInfo{
|
||||
UserID: "test-user-1",
|
||||
TeamID: "test-team-1",
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AgentCaller Tests - Single Call Mode
|
||||
// ============================================================================
|
||||
|
||||
func TestAgentCallerSingleCall(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := standard.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
// Test basic call - verify assistant responds and returns parseable JSON
|
||||
// Note: LLM outputs are non-deterministic, so we test structure not exact values
|
||||
t.Run("basic call returns response", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "Hello, test message")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.IsEmpty(), "result should not be empty")
|
||||
|
||||
// Should be able to get text content
|
||||
text := result.GetText()
|
||||
assert.NotEmpty(t, text, "should have text content")
|
||||
})
|
||||
|
||||
t.Run("call returns parseable JSON", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "Generate inspiration report")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should return parseable JSON (content may vary)
|
||||
data, err := result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
// Verify it has "type" field (all test responses should have this)
|
||||
assert.Contains(t, data, "type", "response should have type field")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentCallerNextHookData(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := standard.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("next_hook inspiration returns structured data", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "next_hook inspiration test")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Next hook should return structured data
|
||||
data, err := result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "inspiration", data["type"])
|
||||
assert.Equal(t, "next_hook", data["source"])
|
||||
})
|
||||
|
||||
t.Run("next_hook goals returns structured data", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "next_hook goals test")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
data, err := result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "goals", data["type"])
|
||||
assert.Equal(t, "next_hook", data["source"])
|
||||
})
|
||||
|
||||
t.Run("next_hook tasks returns structured data", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "next_hook tasks test")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
data, err := result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "tasks", data["type"])
|
||||
assert.Equal(t, "next_hook", data["source"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentCallerJSONArray(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := standard.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("array_test returns JSON array", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "array_test")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
arr, err := result.GetJSONArray()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, arr, 3)
|
||||
|
||||
// Verify first item structure
|
||||
item1, ok := arr[0].(map[string]interface{})
|
||||
require.True(t, ok, "first item should be a map")
|
||||
assert.Equal(t, float64(1), item1["id"])
|
||||
assert.Equal(t, "Item 1", item1["name"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentCallerEmptyResponse(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := standard.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("empty_test falls back to completion content", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "empty_test")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
// When Next hook returns null, should use Completion content
|
||||
assert.False(t, result.IsEmpty())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentCallerAssistantNotFound(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := standard.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("non-existent assistant returns error", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "non.existent.assistant", "hello")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "assistant not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentCallerWithSystemAndUser(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := standard.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("call with system and user messages", func(t *testing.T) {
|
||||
result, err := caller.CallWithSystemAndUser(
|
||||
ctx,
|
||||
"tests.robot-single",
|
||||
"You are a helpful assistant.",
|
||||
"Generate inspiration report",
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.IsEmpty())
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversation Tests - Multi-Turn Mode
|
||||
// ============================================================================
|
||||
|
||||
func TestConversationMultiTurn(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("multi-turn conversation maintains state", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-1", 10)
|
||||
|
||||
// Turn 1: Start planning
|
||||
turn1, err := conv.Turn(ctx, "Plan tasks for sending weekly report")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, turn1)
|
||||
assert.Equal(t, 1, turn1.Turn)
|
||||
|
||||
data1, err := turn1.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
// Verify basic structure - turn number and completed flag
|
||||
assert.Contains(t, data1, "turn")
|
||||
assert.Contains(t, data1, "status")
|
||||
assert.Contains(t, data1, "completed")
|
||||
|
||||
// Turn 2: Continue conversation
|
||||
turn2, err := conv.Turn(ctx, "Send to managers, include sales data")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, turn2)
|
||||
assert.Equal(t, 2, turn2.Turn)
|
||||
|
||||
data2, err := turn2.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, data2, "turn")
|
||||
assert.Contains(t, data2, "status")
|
||||
|
||||
// Turn 3: Complete with confirm/skip
|
||||
turn3, err := conv.Turn(ctx, "skip") // Use skip for deterministic completion
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, turn3)
|
||||
assert.Equal(t, 3, turn3.Turn)
|
||||
|
||||
data3, err := turn3.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "completed", data3["status"])
|
||||
assert.Equal(t, true, data3["completed"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationTurnCount(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("turn count increments correctly", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-2", 10)
|
||||
|
||||
assert.Equal(t, 0, conv.TurnCount())
|
||||
|
||||
_, err := conv.Turn(ctx, "First message")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, conv.TurnCount())
|
||||
|
||||
_, err = conv.Turn(ctx, "Second message")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, conv.TurnCount())
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationMaxTurns(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("exceeding max turns returns error", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-3", 2)
|
||||
|
||||
_, err := conv.Turn(ctx, "First")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = conv.Turn(ctx, "Second")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Third turn should fail
|
||||
_, err = conv.Turn(ctx, "Third")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "max turns (2) exceeded")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationMessages(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("messages history is maintained", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-4", 5)
|
||||
|
||||
// Initially empty
|
||||
assert.Empty(t, conv.Messages())
|
||||
|
||||
// After first turn
|
||||
_, err := conv.Turn(ctx, "Hello")
|
||||
require.NoError(t, err)
|
||||
|
||||
msgs := conv.Messages()
|
||||
assert.GreaterOrEqual(t, len(msgs), 1) // At least user message
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationLastResponse(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("last response returns assistant message", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-5", 5)
|
||||
|
||||
// No response yet
|
||||
assert.Nil(t, conv.LastResponse())
|
||||
|
||||
// After turn
|
||||
_, err := conv.Turn(ctx, "Start planning")
|
||||
require.NoError(t, err)
|
||||
|
||||
lastResp := conv.LastResponse()
|
||||
assert.NotNil(t, lastResp)
|
||||
assert.Equal(t, "assistant", string(lastResp.Role))
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationReset(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("reset clears conversation history", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-6", 5)
|
||||
|
||||
_, err := conv.Turn(ctx, "First message")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, conv.TurnCount())
|
||||
|
||||
conv.Reset()
|
||||
assert.Equal(t, 0, conv.TurnCount())
|
||||
assert.Empty(t, conv.Messages())
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationWithSystemPrompt(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("system prompt is preserved after reset", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-7", 5).
|
||||
WithSystemPrompt("You are a task planner.")
|
||||
|
||||
msgs := conv.Messages()
|
||||
require.Len(t, msgs, 1)
|
||||
assert.Equal(t, "system", string(msgs[0].Role))
|
||||
|
||||
_, err := conv.Turn(ctx, "Hello")
|
||||
require.NoError(t, err)
|
||||
|
||||
conv.Reset()
|
||||
|
||||
// System prompt should be preserved
|
||||
msgs = conv.Messages()
|
||||
require.Len(t, msgs, 1)
|
||||
assert.Equal(t, "system", string(msgs[0].Role))
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationSpecialCommands(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("skip command jumps to completed", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-8", 5)
|
||||
|
||||
turn, err := conv.Turn(ctx, "skip")
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := turn.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "completed", data["status"])
|
||||
assert.Equal(t, true, data["completed"])
|
||||
})
|
||||
|
||||
t.Run("abort command ends conversation", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-9", 5)
|
||||
|
||||
turn, err := conv.Turn(ctx, "abort")
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := turn.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "aborted", data["status"])
|
||||
assert.Equal(t, true, data["completed"])
|
||||
})
|
||||
|
||||
t.Run("reset command resets conversation state", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-10", 5)
|
||||
|
||||
// First do a turn
|
||||
_, err := conv.Turn(ctx, "Start planning")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then reset via command
|
||||
turn, err := conv.Turn(ctx, "reset")
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := turn.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "reset", data["status"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationRunUntil(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("run until completion", func(t *testing.T) {
|
||||
conv := standard.NewConversation("tests.robot-conversation", "test-conv-11", 10)
|
||||
|
||||
inputs := []string{
|
||||
"Plan weekly report tasks",
|
||||
"Send to team leads, include metrics",
|
||||
"confirm",
|
||||
}
|
||||
inputIdx := 0
|
||||
|
||||
results, err := conv.RunUntil(
|
||||
ctx,
|
||||
func(turn int, lastResult *standard.CallResult) (string, error) {
|
||||
if inputIdx < len(inputs) {
|
||||
input := inputs[inputIdx]
|
||||
inputIdx++
|
||||
return input, nil
|
||||
}
|
||||
return "confirm", nil
|
||||
},
|
||||
func(turn int, result *standard.CallResult) (bool, error) {
|
||||
data, err := result.GetJSON()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
completed, ok := data["completed"].(bool)
|
||||
return ok && completed, nil
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 3, "should complete in 3 turns")
|
||||
|
||||
// Final result should be completed
|
||||
finalData, err := results[len(results)-1].Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, finalData["completed"])
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CallResult Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestCallResultGetText(t *testing.T) {
|
||||
t.Run("returns content when available", func(t *testing.T) {
|
||||
result := &standard.CallResult{Content: "Hello World"}
|
||||
assert.Equal(t, "Hello World", result.GetText())
|
||||
})
|
||||
|
||||
t.Run("returns empty for empty result", func(t *testing.T) {
|
||||
result := &standard.CallResult{}
|
||||
assert.Equal(t, "", result.GetText())
|
||||
})
|
||||
}
|
||||
|
||||
func TestCallResultIsEmpty(t *testing.T) {
|
||||
t.Run("empty when no content and no next", func(t *testing.T) {
|
||||
result := &standard.CallResult{}
|
||||
assert.True(t, result.IsEmpty())
|
||||
})
|
||||
|
||||
t.Run("not empty when has content", func(t *testing.T) {
|
||||
result := &standard.CallResult{Content: "test"}
|
||||
assert.False(t, result.IsEmpty())
|
||||
})
|
||||
|
||||
t.Run("not empty when has next", func(t *testing.T) {
|
||||
result := &standard.CallResult{Next: map[string]interface{}{"key": "value"}}
|
||||
assert.False(t, result.IsEmpty())
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ExtractCodeBlock Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestExtractCodeBlock(t *testing.T) {
|
||||
t.Run("extracts JSON code block", func(t *testing.T) {
|
||||
content := "Here is the result:\n```json\n{\"key\": \"value\"}\n```"
|
||||
block := standard.ExtractCodeBlock(content)
|
||||
|
||||
require.NotNil(t, block)
|
||||
assert.Equal(t, "json", block.Type)
|
||||
assert.Contains(t, block.Content, "key")
|
||||
})
|
||||
|
||||
t.Run("returns nil for no code block", func(t *testing.T) {
|
||||
content := "Just plain text"
|
||||
block := standard.ExtractCodeBlock(content)
|
||||
|
||||
// gou/text returns text type for plain text
|
||||
require.NotNil(t, block)
|
||||
assert.Equal(t, "text", block.Type)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractAllCodeBlocks(t *testing.T) {
|
||||
t.Run("extracts multiple code blocks", func(t *testing.T) {
|
||||
content := "```json\n{}\n```\n\n```python\nprint('hello')\n```"
|
||||
blocks := standard.ExtractAllCodeBlocks(content)
|
||||
|
||||
assert.Len(t, blocks, 2)
|
||||
})
|
||||
}
|
||||
32
agent/robot/executor/standard/delivery.go
Normal file
32
agent/robot/executor/standard/delivery.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunDelivery executes P4: Delivery phase
|
||||
// Delivers results to configured targets
|
||||
//
|
||||
// Input:
|
||||
// - TaskResults (from P3)
|
||||
// - Delivery config from robot
|
||||
//
|
||||
// Output:
|
||||
// - DeliveryResult with success status
|
||||
//
|
||||
// Delivery Types:
|
||||
// - DeliveryEmail: Send email
|
||||
// - DeliveryNotify: Send notification
|
||||
// - DeliveryWebhook: Call webhook
|
||||
// - DeliveryStore: Store to database
|
||||
//
|
||||
// TODO: Implement real delivery
|
||||
func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
||||
e.simulateStreamDelay()
|
||||
|
||||
exec.Delivery = &robottypes.DeliveryResult{
|
||||
Type: robottypes.DeliveryNotify,
|
||||
Success: true,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
224
agent/robot/executor/standard/executor.go
Normal file
224
agent/robot/executor/standard/executor.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/robot/executor/types"
|
||||
"github.com/yaoapp/yao/agent/robot/job"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Executor implements the standard executor with real Agent calls
|
||||
// This is the production executor that:
|
||||
// - Creates Job records for tracking
|
||||
// - Calls real Agents via Assistant.Stream()
|
||||
// - Logs phase transitions and errors
|
||||
type Executor struct {
|
||||
config types.Config
|
||||
execCount atomic.Int32
|
||||
currentCount atomic.Int32
|
||||
onStart func()
|
||||
onEnd func()
|
||||
}
|
||||
|
||||
// New creates a new standard executor
|
||||
func New() *Executor {
|
||||
return &Executor{}
|
||||
}
|
||||
|
||||
// NewWithConfig creates a new standard executor with configuration
|
||||
func NewWithConfig(config types.Config) *Executor {
|
||||
return &Executor{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute runs a robot through all applicable phases with real Agent calls
|
||||
func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, trigger robottypes.TriggerType, data interface{}) (*robottypes.Execution, error) {
|
||||
if robot == nil {
|
||||
return nil, fmt.Errorf("robot cannot be nil")
|
||||
}
|
||||
|
||||
var exec *robottypes.Execution
|
||||
var err error
|
||||
|
||||
// Determine starting phase based on trigger type
|
||||
startPhaseIndex := 0
|
||||
if trigger == robottypes.TriggerHuman || trigger == robottypes.TriggerEvent {
|
||||
startPhaseIndex = 1 // Skip P0 (Inspiration)
|
||||
}
|
||||
|
||||
// Create execution with Job integration
|
||||
if !e.config.SkipJobIntegration {
|
||||
exec, err = job.CreateExecution(ctx, &job.CreateOptions{
|
||||
Robot: robot,
|
||||
TriggerType: trigger,
|
||||
Input: types.BuildTriggerInput(trigger, data),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create execution: %w", err)
|
||||
}
|
||||
} else {
|
||||
exec = &robottypes.Execution{
|
||||
ID: fmt.Sprintf("exec_%d", time.Now().UnixNano()),
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
TriggerType: trigger,
|
||||
StartTime: time.Now(),
|
||||
Status: robottypes.ExecPending,
|
||||
Phase: robottypes.AllPhases[startPhaseIndex],
|
||||
Input: types.BuildTriggerInput(trigger, data),
|
||||
}
|
||||
}
|
||||
|
||||
// Set robot reference for phase methods
|
||||
exec.SetRobot(robot)
|
||||
|
||||
// Acquire execution slot
|
||||
if !robot.TryAcquireSlot(exec) {
|
||||
if !e.config.SkipJobIntegration && exec.JobID != "" {
|
||||
_ = job.FailExecution(ctx, exec, robottypes.ErrQuotaExceeded)
|
||||
}
|
||||
return nil, robottypes.ErrQuotaExceeded
|
||||
}
|
||||
defer robot.RemoveExecution(exec.ID)
|
||||
|
||||
// Track execution count
|
||||
e.execCount.Add(1)
|
||||
e.currentCount.Add(1)
|
||||
defer e.currentCount.Add(-1)
|
||||
|
||||
// Callbacks
|
||||
if e.onStart != nil {
|
||||
e.onStart()
|
||||
}
|
||||
if e.onEnd != nil {
|
||||
defer e.onEnd()
|
||||
}
|
||||
|
||||
// Update status to running
|
||||
exec.Status = robottypes.ExecRunning
|
||||
if !e.config.SkipJobIntegration {
|
||||
if err := job.UpdateStatus(ctx, exec, robottypes.ExecRunning); err != nil {
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update status to running: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Check for simulated failure (for testing)
|
||||
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
|
||||
exec.Status = robottypes.ExecFailed
|
||||
exec.Error = "simulated failure"
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.FailExecution(ctx, exec, fmt.Errorf("simulated failure"))
|
||||
}
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
// Execute phases
|
||||
phases := robottypes.AllPhases[startPhaseIndex:]
|
||||
for _, phase := range phases {
|
||||
if err := e.runPhase(ctx, exec, phase, data); err != nil {
|
||||
exec.Status = robottypes.ExecFailed
|
||||
exec.Error = err.Error()
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.FailExecution(ctx, exec, err)
|
||||
}
|
||||
return exec, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Mark completed
|
||||
exec.Status = robottypes.ExecCompleted
|
||||
now := time.Now()
|
||||
exec.EndTime = &now
|
||||
|
||||
if !e.config.SkipJobIntegration {
|
||||
if err := job.CompleteExecution(ctx, exec); err != nil {
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to mark execution as completed: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
// runPhase executes a single phase
|
||||
func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, phase robottypes.Phase, data interface{}) error {
|
||||
exec.Phase = phase
|
||||
|
||||
if !e.config.SkipJobIntegration {
|
||||
if err := job.UpdatePhase(ctx, exec, phase); err != nil {
|
||||
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update phase to %s: %v", phase, err))
|
||||
}
|
||||
}
|
||||
|
||||
if e.config.OnPhaseStart != nil {
|
||||
e.config.OnPhaseStart(phase)
|
||||
}
|
||||
|
||||
phaseStart := time.Now()
|
||||
|
||||
// Execute phase-specific logic
|
||||
var err error
|
||||
switch phase {
|
||||
case robottypes.PhaseInspiration:
|
||||
err = e.RunInspiration(ctx, exec, data)
|
||||
case robottypes.PhaseGoals:
|
||||
err = e.RunGoals(ctx, exec, data)
|
||||
case robottypes.PhaseTasks:
|
||||
err = e.RunTasks(ctx, exec, data)
|
||||
case robottypes.PhaseRun:
|
||||
err = e.RunExecution(ctx, exec, data)
|
||||
case robottypes.PhaseDelivery:
|
||||
err = e.RunDelivery(ctx, exec, data)
|
||||
case robottypes.PhaseLearning:
|
||||
err = e.RunLearning(ctx, exec, data)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if !e.config.SkipJobIntegration {
|
||||
_ = job.LogPhaseError(ctx, exec, phase, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if e.config.OnPhaseEnd != nil {
|
||||
e.config.OnPhaseEnd(phase)
|
||||
}
|
||||
|
||||
if !e.config.SkipJobIntegration {
|
||||
phaseDuration := time.Since(phaseStart).Milliseconds()
|
||||
_ = job.LogPhaseEnd(ctx, exec, phase, phaseDuration)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecCount returns total execution count
|
||||
func (e *Executor) ExecCount() int {
|
||||
return int(e.execCount.Load())
|
||||
}
|
||||
|
||||
// CurrentCount returns currently running execution count
|
||||
func (e *Executor) CurrentCount() int {
|
||||
return int(e.currentCount.Load())
|
||||
}
|
||||
|
||||
// Reset resets the executor counters
|
||||
func (e *Executor) Reset() {
|
||||
e.execCount.Store(0)
|
||||
e.currentCount.Store(0)
|
||||
}
|
||||
|
||||
// DefaultStreamDelay is the simulated delay for Agent Stream calls
|
||||
// This will be removed when real Agent calls are implemented
|
||||
const DefaultStreamDelay = 50 * time.Millisecond
|
||||
|
||||
// simulateStreamDelay simulates the delay of an Agent Stream call
|
||||
func (e *Executor) simulateStreamDelay() {
|
||||
time.Sleep(DefaultStreamDelay)
|
||||
}
|
||||
|
||||
// Verify Executor implements types.Executor
|
||||
var _ types.Executor = (*Executor)(nil)
|
||||
178
agent/robot/executor/standard/goals.go
Normal file
178
agent/robot/executor/standard/goals.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunGoals executes P1: Goals phase
|
||||
// Calls the Goals Agent to plan daily objectives
|
||||
//
|
||||
// Input:
|
||||
// - InspirationReport (from P0) for clock trigger
|
||||
// - TriggerInput for human/event trigger
|
||||
//
|
||||
// Output:
|
||||
// - Goals with markdown content and delivery info
|
||||
func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
||||
// Get robot for identity and resources
|
||||
robot := exec.GetRobot()
|
||||
if robot == nil {
|
||||
return fmt.Errorf("robot not found in execution")
|
||||
}
|
||||
|
||||
// Get agent ID for goals phase
|
||||
agentID := "__yao.goals" // default
|
||||
if robot.Config != nil && robot.Config.Resources != nil {
|
||||
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseGoals)
|
||||
}
|
||||
|
||||
// Build prompt based on trigger type
|
||||
formatter := NewInputFormatter()
|
||||
var userContent string
|
||||
|
||||
switch exec.TriggerType {
|
||||
case robottypes.TriggerClock:
|
||||
// For clock trigger: use InspirationReport from P0
|
||||
if exec.Inspiration != nil {
|
||||
userContent = formatter.FormatInspirationReport(exec.Inspiration)
|
||||
} else {
|
||||
// Fallback: if no inspiration report, create minimal context
|
||||
userContent = formatter.FormatClockContext(
|
||||
robottypes.NewClockContext(exec.StartTime, ""),
|
||||
robot,
|
||||
)
|
||||
}
|
||||
|
||||
case robottypes.TriggerHuman, robottypes.TriggerEvent:
|
||||
// For human/event trigger: use TriggerInput directly
|
||||
if exec.Input != nil {
|
||||
userContent = formatter.FormatTriggerInput(exec.Input)
|
||||
}
|
||||
}
|
||||
|
||||
// Add robot identity context if not already included
|
||||
// For clock trigger with inspiration report, identity is not in the report
|
||||
// For human/event trigger, identity provides context
|
||||
if robot.Config != nil && robot.Config.Identity != nil {
|
||||
if !strings.Contains(userContent, "## Robot Identity") {
|
||||
userContent = formatter.FormatRobotIdentity(robot) + "\n\n" + userContent
|
||||
}
|
||||
}
|
||||
|
||||
// Add available resources - critical for generating achievable goals
|
||||
// Without knowing what tools are available, goals might be unachievable
|
||||
resourcesContent := formatter.FormatAvailableResources(robot)
|
||||
if resourcesContent != "" {
|
||||
userContent += "\n\n" + resourcesContent
|
||||
}
|
||||
|
||||
if userContent == "" {
|
||||
return fmt.Errorf("no input available for goals generation")
|
||||
}
|
||||
|
||||
// Call agent
|
||||
caller := NewAgentCaller()
|
||||
result, err := caller.CallWithMessages(ctx, agentID, userContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goals agent (%s) call failed: %w", agentID, err)
|
||||
}
|
||||
|
||||
// Parse response as JSON
|
||||
// Goals Agent returns: { "content": "...", "delivery": {...} }
|
||||
data, err := result.GetJSON()
|
||||
if err != nil {
|
||||
// Fallback: if not JSON, use raw text as content
|
||||
content := result.GetText()
|
||||
if content == "" {
|
||||
return fmt.Errorf("goals agent returned empty response")
|
||||
}
|
||||
exec.Goals = &robottypes.Goals{
|
||||
Content: content,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build Goals from JSON
|
||||
exec.Goals = &robottypes.Goals{}
|
||||
|
||||
// Extract content (markdown)
|
||||
if content, ok := data["content"].(string); ok {
|
||||
exec.Goals.Content = content
|
||||
}
|
||||
|
||||
// Extract delivery
|
||||
if delivery, ok := data["delivery"].(map[string]interface{}); ok {
|
||||
exec.Goals.Delivery = ParseDelivery(delivery)
|
||||
}
|
||||
|
||||
// Validate: content is required
|
||||
if exec.Goals.Content == "" {
|
||||
return fmt.Errorf("goals agent (%s) returned empty content", agentID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseDelivery converts map to DeliveryTarget struct
|
||||
// Returns nil if data is nil or type is invalid/missing
|
||||
func ParseDelivery(data map[string]interface{}) *robottypes.DeliveryTarget {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Type is required - if missing or invalid, return nil
|
||||
t, ok := data["type"].(string)
|
||||
if !ok || t == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
deliveryType := robottypes.DeliveryType(t)
|
||||
if !IsValidDeliveryType(deliveryType) {
|
||||
// Invalid type - return nil to indicate parsing failure
|
||||
return nil
|
||||
}
|
||||
|
||||
target := &robottypes.DeliveryTarget{
|
||||
Type: deliveryType,
|
||||
}
|
||||
|
||||
// Parse recipients
|
||||
if recipients, ok := data["recipients"].([]interface{}); ok {
|
||||
for _, r := range recipients {
|
||||
if s, ok := r.(string); ok {
|
||||
target.Recipients = append(target.Recipients, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse format
|
||||
if format, ok := data["format"].(string); ok {
|
||||
target.Format = format
|
||||
}
|
||||
|
||||
// Parse template
|
||||
if template, ok := data["template"].(string); ok {
|
||||
target.Template = template
|
||||
}
|
||||
|
||||
// Parse options
|
||||
if options, ok := data["options"].(map[string]interface{}); ok {
|
||||
target.Options = options
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
// IsValidDeliveryType checks if the delivery type is valid
|
||||
func IsValidDeliveryType(t robottypes.DeliveryType) bool {
|
||||
switch t {
|
||||
case robottypes.DeliveryEmail, robottypes.DeliveryWebhook,
|
||||
robottypes.DeliveryFile, robottypes.DeliveryNotify:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
601
agent/robot/executor/standard/goals_test.go
Normal file
601
agent/robot/executor/standard/goals_test.go
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
package standard_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// P1 Goals Phase Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestRunGoalsBasic(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 goals from inspiration report (clock trigger)", func(t *testing.T) {
|
||||
// Create robot with goals agent configured
|
||||
robot := createGoalsTestRobot(t, "robot.goals")
|
||||
|
||||
// Create execution with inspiration report (from P0)
|
||||
exec := createGoalsTestExecution(robot, types.TriggerClock)
|
||||
exec.Inspiration = &types.InspirationReport{
|
||||
Clock: types.NewClockContext(time.Now(), ""),
|
||||
Content: "## Summary\nToday is Monday morning. Focus on weekly planning.\n\n## Highlights\n- New sales leads arrived\n- Weekly report due Friday",
|
||||
}
|
||||
|
||||
// Run goals phase
|
||||
e := standard.New()
|
||||
err := e.RunGoals(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec.Goals)
|
||||
assert.NotEmpty(t, exec.Goals.Content)
|
||||
})
|
||||
|
||||
t.Run("includes priority markers in output", func(t *testing.T) {
|
||||
robot := createGoalsTestRobot(t, "robot.goals")
|
||||
exec := createGoalsTestExecution(robot, types.TriggerClock)
|
||||
exec.Inspiration = &types.InspirationReport{
|
||||
Clock: types.NewClockContext(time.Now(), ""),
|
||||
Content: "## Summary\nUrgent: Customer complaint needs attention.\n\n## Highlights\n- Critical bug reported\n- Regular maintenance scheduled",
|
||||
}
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunGoals(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
content := exec.Goals.Content
|
||||
|
||||
// Verify expected structure in markdown output
|
||||
// Note: LLM output is non-deterministic, so we check for likely patterns
|
||||
hasGoals := strings.Contains(content, "Goal") ||
|
||||
strings.Contains(content, "##") ||
|
||||
strings.Contains(content, "High") ||
|
||||
strings.Contains(content, "Normal") ||
|
||||
strings.Contains(content, "1.")
|
||||
|
||||
assert.True(t, hasGoals, "should contain goals structure, got: %s", content)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunGoalsHumanTrigger(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 goals from human intervention", func(t *testing.T) {
|
||||
robot := createGoalsTestRobot(t, "robot.goals")
|
||||
exec := createGoalsTestExecution(robot, types.TriggerHuman)
|
||||
|
||||
// Set human intervention input
|
||||
exec.Input = &types.TriggerInput{
|
||||
Action: "task.add",
|
||||
UserID: "user-123",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Please analyze the Q4 sales data and prepare a summary report for the management meeting tomorrow."},
|
||||
},
|
||||
}
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunGoals(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec.Goals)
|
||||
assert.NotEmpty(t, exec.Goals.Content)
|
||||
|
||||
// Goals should be related to the user request
|
||||
content := strings.ToLower(exec.Goals.Content)
|
||||
hasRelevantContent := strings.Contains(content, "sales") ||
|
||||
strings.Contains(content, "report") ||
|
||||
strings.Contains(content, "analysis") ||
|
||||
strings.Contains(content, "data") ||
|
||||
strings.Contains(content, "q4")
|
||||
|
||||
assert.True(t, hasRelevantContent, "goals should relate to user request, got: %s", exec.Goals.Content)
|
||||
})
|
||||
|
||||
t.Run("includes robot identity for human trigger", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-robot-1",
|
||||
TeamID: "test-team-1",
|
||||
DisplayName: "Sales Analyst",
|
||||
Config: &types.Config{
|
||||
Identity: &types.Identity{
|
||||
Role: "Sales Analyst",
|
||||
Duties: []string{"Analyze sales data", "Generate reports"},
|
||||
Rules: []string{"Focus on actionable insights"},
|
||||
},
|
||||
Resources: &types.Resources{
|
||||
Phases: map[types.Phase]string{
|
||||
types.PhaseGoals: "robot.goals",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
exec := createGoalsTestExecution(robot, types.TriggerHuman)
|
||||
exec.Input = &types.TriggerInput{
|
||||
Action: "instruct",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "What should I focus on today?"},
|
||||
},
|
||||
}
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunGoals(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, exec.Goals.Content)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunGoalsEventTrigger(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 goals from event trigger", func(t *testing.T) {
|
||||
robot := createGoalsTestRobot(t, "robot.goals")
|
||||
exec := createGoalsTestExecution(robot, types.TriggerEvent)
|
||||
|
||||
// Set event input
|
||||
exec.Input = &types.TriggerInput{
|
||||
Source: "webhook",
|
||||
EventType: "lead.created",
|
||||
Data: map[string]interface{}{
|
||||
"lead_id": "lead-456",
|
||||
"company": "BigCorp Inc",
|
||||
"contact_name": "John Smith",
|
||||
"email": "john@bigcorp.com",
|
||||
"interest": "Enterprise plan",
|
||||
},
|
||||
}
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunGoals(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec.Goals)
|
||||
assert.NotEmpty(t, exec.Goals.Content)
|
||||
|
||||
// Goals should be related to the event
|
||||
content := strings.ToLower(exec.Goals.Content)
|
||||
hasRelevantContent := strings.Contains(content, "lead") ||
|
||||
strings.Contains(content, "bigcorp") ||
|
||||
strings.Contains(content, "contact") ||
|
||||
strings.Contains(content, "follow") ||
|
||||
strings.Contains(content, "qualify")
|
||||
|
||||
assert.True(t, hasRelevantContent, "goals should relate to event, got: %s", exec.Goals.Content)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunGoalsErrorHandling(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.RunGoals(ctx, exec, nil)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "robot not found")
|
||||
})
|
||||
|
||||
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.PhaseGoals: "non.existent.agent",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
exec := createGoalsTestExecution(robot, types.TriggerClock)
|
||||
exec.Inspiration = &types.InspirationReport{
|
||||
Content: "Test content",
|
||||
}
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunGoals(ctx, exec, nil)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "call failed")
|
||||
})
|
||||
|
||||
t.Run("returns error when no input available and no identity", func(t *testing.T) {
|
||||
// Robot without identity - should fail when no input is provided
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-robot-1",
|
||||
TeamID: "test-team-1",
|
||||
Config: &types.Config{
|
||||
// No Identity - so no fallback content
|
||||
Resources: &types.Resources{
|
||||
Phases: map[types.Phase]string{
|
||||
types.PhaseGoals: "robot.goals",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
exec := createGoalsTestExecution(robot, types.TriggerHuman)
|
||||
exec.Input = nil // No input
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunGoals(ctx, exec, nil)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no input available")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunGoalsFallbackBehavior(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("falls back to clock context when no inspiration report", func(t *testing.T) {
|
||||
robot := createGoalsTestRobot(t, "robot.goals")
|
||||
exec := createGoalsTestExecution(robot, types.TriggerClock)
|
||||
exec.Inspiration = nil // No inspiration report
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunGoals(ctx, exec, nil)
|
||||
|
||||
// Should still work with fallback clock context
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec.Goals)
|
||||
assert.NotEmpty(t, exec.Goals.Content)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Delivery Parsing Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestParseDeliveryFromGoalsResponse(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 delivery when agent returns it", func(t *testing.T) {
|
||||
robot := createGoalsTestRobot(t, "robot.goals")
|
||||
exec := createGoalsTestExecution(robot, types.TriggerHuman)
|
||||
|
||||
// Request that explicitly asks for email delivery
|
||||
exec.Input = &types.TriggerInput{
|
||||
Action: "task.add",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Prepare a sales report and send it to team@example.com via email"},
|
||||
},
|
||||
}
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunGoals(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec.Goals)
|
||||
assert.NotEmpty(t, exec.Goals.Content)
|
||||
|
||||
// Delivery may or may not be present depending on LLM response
|
||||
// If present, verify structure
|
||||
if exec.Goals.Delivery != nil {
|
||||
// Type should be valid if present
|
||||
if exec.Goals.Delivery.Type != "" {
|
||||
validTypes := []types.DeliveryType{
|
||||
types.DeliveryEmail, types.DeliveryWebhook,
|
||||
types.DeliveryFile, types.DeliveryNotify,
|
||||
}
|
||||
found := false
|
||||
for _, vt := range validTypes {
|
||||
if exec.Goals.Delivery.Type == vt {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Note: LLM might return non-standard types, we accept them but log
|
||||
t.Logf("Delivery type: %s (valid: %v)", exec.Goals.Delivery.Type, found)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeliveryTypeValidation(t *testing.T) {
|
||||
t.Run("valid delivery types", func(t *testing.T) {
|
||||
validTypes := []types.DeliveryType{
|
||||
types.DeliveryEmail,
|
||||
types.DeliveryWebhook,
|
||||
types.DeliveryFile,
|
||||
types.DeliveryNotify,
|
||||
}
|
||||
|
||||
for _, dt := range validTypes {
|
||||
assert.True(t, standard.IsValidDeliveryType(dt), "should be valid: %s", dt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid delivery types", func(t *testing.T) {
|
||||
invalidTypes := []types.DeliveryType{
|
||||
"invalid",
|
||||
"sms",
|
||||
"",
|
||||
}
|
||||
|
||||
for _, dt := range invalidTypes {
|
||||
assert.False(t, standard.IsValidDeliveryType(dt), "should be invalid: %s", dt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseDelivery(t *testing.T) {
|
||||
t.Run("parses valid delivery with all fields", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"type": "email",
|
||||
"recipients": []interface{}{"user@example.com", "team@example.com"},
|
||||
"format": "markdown",
|
||||
"template": "weekly-report",
|
||||
"options": map[string]interface{}{
|
||||
"subject": "Weekly Report",
|
||||
},
|
||||
}
|
||||
|
||||
result := standard.ParseDelivery(data)
|
||||
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, types.DeliveryEmail, result.Type)
|
||||
assert.Equal(t, []string{"user@example.com", "team@example.com"}, result.Recipients)
|
||||
assert.Equal(t, "markdown", result.Format)
|
||||
assert.Equal(t, "weekly-report", result.Template)
|
||||
assert.Equal(t, "Weekly Report", result.Options["subject"])
|
||||
})
|
||||
|
||||
t.Run("returns nil for nil data", func(t *testing.T) {
|
||||
result := standard.ParseDelivery(nil)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("returns nil for missing type", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"recipients": []interface{}{"user@example.com"},
|
||||
}
|
||||
|
||||
result := standard.ParseDelivery(data)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("returns nil for empty type", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"type": "",
|
||||
"recipients": []interface{}{"user@example.com"},
|
||||
}
|
||||
|
||||
result := standard.ParseDelivery(data)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("returns nil for invalid type", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"type": "sms",
|
||||
"recipients": []interface{}{"user@example.com"},
|
||||
}
|
||||
|
||||
result := standard.ParseDelivery(data)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("handles missing optional fields", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"type": "webhook",
|
||||
}
|
||||
|
||||
result := standard.ParseDelivery(data)
|
||||
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, types.DeliveryWebhook, result.Type)
|
||||
assert.Empty(t, result.Recipients)
|
||||
assert.Empty(t, result.Format)
|
||||
assert.Empty(t, result.Template)
|
||||
assert.Nil(t, result.Options)
|
||||
})
|
||||
|
||||
t.Run("handles non-string recipients gracefully", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"type": "email",
|
||||
"recipients": []interface{}{"valid@example.com", 123, nil, "another@example.com"},
|
||||
}
|
||||
|
||||
result := standard.ParseDelivery(data)
|
||||
|
||||
require.NotNil(t, result)
|
||||
// Only string recipients should be included
|
||||
assert.Equal(t, []string{"valid@example.com", "another@example.com"}, result.Recipients)
|
||||
})
|
||||
|
||||
t.Run("parses all valid delivery types", func(t *testing.T) {
|
||||
validTypes := []string{"email", "webhook", "file", "notify"}
|
||||
|
||||
for _, dt := range validTypes {
|
||||
data := map[string]interface{}{
|
||||
"type": dt,
|
||||
}
|
||||
|
||||
result := standard.ParseDelivery(data)
|
||||
require.NotNil(t, result, "should parse type: %s", dt)
|
||||
assert.Equal(t, types.DeliveryType(dt), result.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InputFormatter Tests for P1
|
||||
// ============================================================================
|
||||
|
||||
func TestInputFormatterFormatRobotIdentity(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("formats robot identity correctly", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-robot",
|
||||
Config: &types.Config{
|
||||
Identity: &types.Identity{
|
||||
Role: "Sales Analyst",
|
||||
Duties: []string{"Analyze sales data", "Generate reports"},
|
||||
Rules: []string{"Be accurate", "Be concise"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
content := formatter.FormatRobotIdentity(robot)
|
||||
|
||||
assert.Contains(t, content, "## Robot Identity")
|
||||
assert.Contains(t, content, "Sales Analyst")
|
||||
assert.Contains(t, content, "Analyze sales data")
|
||||
assert.Contains(t, content, "Generate reports")
|
||||
assert.Contains(t, content, "Be accurate")
|
||||
assert.Contains(t, content, "Be concise")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil robot", func(t *testing.T) {
|
||||
content := formatter.FormatRobotIdentity(nil)
|
||||
assert.Empty(t, content)
|
||||
})
|
||||
|
||||
t.Run("returns empty for robot without config", func(t *testing.T) {
|
||||
robot := &types.Robot{MemberID: "test"}
|
||||
content := formatter.FormatRobotIdentity(robot)
|
||||
assert.Empty(t, content)
|
||||
})
|
||||
|
||||
t.Run("returns empty for robot without identity", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
MemberID: "test",
|
||||
Config: &types.Config{},
|
||||
}
|
||||
content := formatter.FormatRobotIdentity(robot)
|
||||
assert.Empty(t, content)
|
||||
})
|
||||
|
||||
t.Run("handles identity with only role", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
MemberID: "test",
|
||||
Config: &types.Config{
|
||||
Identity: &types.Identity{
|
||||
Role: "Simple Bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
content := formatter.FormatRobotIdentity(robot)
|
||||
|
||||
assert.Contains(t, content, "## Robot Identity")
|
||||
assert.Contains(t, content, "Simple Bot")
|
||||
assert.NotContains(t, content, "Duties")
|
||||
assert.NotContains(t, content, "Rules")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
// createGoalsTestRobot creates a test robot with specified goals agent
|
||||
// Includes available expert agents so the Goals Agent knows what resources are available
|
||||
//
|
||||
// Note: The agent IDs listed in Resources.Agents must exist in yao-dev-app/assistants/experts/
|
||||
// Current available experts: data-analyst, summarizer, text-writer, web-reader
|
||||
func createGoalsTestRobot(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", "Validation", "Data Analysis", "Report Generation"},
|
||||
},
|
||||
Resources: &types.Resources{
|
||||
Phases: map[types.Phase]string{
|
||||
types.PhaseGoals: agentID,
|
||||
},
|
||||
// Available expert agents that can be delegated to
|
||||
// These IDs correspond to assistants in yao-dev-app/assistants/experts/
|
||||
Agents: []string{
|
||||
"experts.data-analyst", // Data analysis and insights
|
||||
"experts.summarizer", // Content summarization
|
||||
"experts.text-writer", // Report and document generation
|
||||
"experts.web-reader", // Web content extraction
|
||||
},
|
||||
},
|
||||
// Knowledge base collections (if any)
|
||||
KB: &types.KB{
|
||||
Collections: []string{"test-knowledge"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createGoalsTestExecution creates a test execution for goals phase
|
||||
func createGoalsTestExecution(robot *types.Robot, trigger types.TriggerType) *types.Execution {
|
||||
exec := &types.Execution{
|
||||
ID: "test-exec-goals-1",
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
TriggerType: trigger,
|
||||
StartTime: time.Now(),
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseGoals,
|
||||
}
|
||||
exec.SetRobot(robot)
|
||||
return exec
|
||||
}
|
||||
531
agent/robot/executor/standard/input.go
Normal file
531
agent/robot/executor/standard/input.go
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// InputFormatter provides methods to format input data for assistant prompts
|
||||
// Each phase has specific input requirements:
|
||||
// - P0 (Inspiration): ClockContext + Robot identity + Available resources
|
||||
// - P1 (Goals): InspirationReport/TriggerInput + Robot identity + Available resources
|
||||
// - P2 (Tasks): Goals + Available resources
|
||||
// - P3 (Run): Tasks
|
||||
// - P4 (Delivery): Task results
|
||||
// - P5 (Learning): Execution summary
|
||||
type InputFormatter struct{}
|
||||
|
||||
// NewInputFormatter creates a new InputFormatter
|
||||
func NewInputFormatter() *InputFormatter {
|
||||
return &InputFormatter{}
|
||||
}
|
||||
|
||||
// FormatClockContext formats ClockContext as user message content
|
||||
// Used by P0 (Inspiration) phase
|
||||
func (f *InputFormatter) FormatClockContext(clock *robottypes.ClockContext, robot *robottypes.Robot) string {
|
||||
if clock == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Time context section
|
||||
sb.WriteString("## Current Time Context\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Now**: %s\n", clock.Now.Format("2006-01-02 15:04:05")))
|
||||
sb.WriteString(fmt.Sprintf("- **Day**: %s\n", clock.DayOfWeek))
|
||||
sb.WriteString(fmt.Sprintf("- **Date**: %d/%d/%d\n", clock.Year, clock.Month, clock.DayOfMonth))
|
||||
sb.WriteString(fmt.Sprintf("- **Week**: %d of year\n", clock.WeekOfYear))
|
||||
sb.WriteString(fmt.Sprintf("- **Timezone**: %s\n", clock.TZ))
|
||||
|
||||
// Time markers
|
||||
sb.WriteString("\n### Time Markers\n")
|
||||
if clock.IsWeekend {
|
||||
sb.WriteString("- ✓ Weekend\n")
|
||||
}
|
||||
if clock.IsMonthStart {
|
||||
sb.WriteString("- ✓ Month Start (1st-3rd)\n")
|
||||
}
|
||||
if clock.IsMonthEnd {
|
||||
sb.WriteString("- ✓ Month End (last 3 days)\n")
|
||||
}
|
||||
if clock.IsQuarterEnd {
|
||||
sb.WriteString("- ✓ Quarter End\n")
|
||||
}
|
||||
if clock.IsYearEnd {
|
||||
sb.WriteString("- ✓ Year End\n")
|
||||
}
|
||||
|
||||
// Robot identity section (if available)
|
||||
if robot != nil && robot.Config != nil && robot.Config.Identity != nil {
|
||||
sb.WriteString("\n## Robot Identity\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Role**: %s\n", robot.Config.Identity.Role))
|
||||
if len(robot.Config.Identity.Duties) > 0 {
|
||||
sb.WriteString("- **Duties**:\n")
|
||||
for _, duty := range robot.Config.Identity.Duties {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", duty))
|
||||
}
|
||||
}
|
||||
if len(robot.Config.Identity.Rules) > 0 {
|
||||
sb.WriteString("- **Rules**:\n")
|
||||
for _, rule := range robot.Config.Identity.Rules {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", rule))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatRobotIdentity formats robot identity as user message content
|
||||
// Used to provide context about the robot's role and duties
|
||||
func (f *InputFormatter) FormatRobotIdentity(robot *robottypes.Robot) string {
|
||||
if robot == nil || robot.Config == nil || robot.Config.Identity == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
identity := robot.Config.Identity
|
||||
|
||||
sb.WriteString("## Robot Identity\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Role**: %s\n", identity.Role))
|
||||
|
||||
if len(identity.Duties) > 0 {
|
||||
sb.WriteString("- **Duties**:\n")
|
||||
for _, duty := range identity.Duties {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", duty))
|
||||
}
|
||||
}
|
||||
|
||||
if len(identity.Rules) > 0 {
|
||||
sb.WriteString("- **Rules**:\n")
|
||||
for _, rule := range identity.Rules {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", rule))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatAvailableResources formats available resources (agents, MCP tools, KB, DB) as user message content
|
||||
// Used by P0 (Inspiration) and P1 (Goals) to inform the agent what tools are available
|
||||
// This is critical for generating achievable goals - without knowing available tools,
|
||||
// the agent might generate goals that cannot be accomplished
|
||||
func (f *InputFormatter) FormatAvailableResources(robot *robottypes.Robot) string {
|
||||
if robot == nil || robot.Config == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
hasContent := false
|
||||
|
||||
// Available Agents
|
||||
if robot.Config.Resources != nil && len(robot.Config.Resources.Agents) > 0 {
|
||||
if !hasContent {
|
||||
sb.WriteString("## Available Resources\n\n")
|
||||
hasContent = true
|
||||
}
|
||||
sb.WriteString("### Agents\n")
|
||||
sb.WriteString("These are the AI assistants you can delegate tasks to:\n")
|
||||
for _, agent := range robot.Config.Resources.Agents {
|
||||
sb.WriteString(fmt.Sprintf("- **%s**\n", agent))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Available MCP Tools
|
||||
if robot.Config.Resources != nil && len(robot.Config.Resources.MCP) > 0 {
|
||||
if !hasContent {
|
||||
sb.WriteString("## Available Resources\n\n")
|
||||
hasContent = true
|
||||
}
|
||||
sb.WriteString("### MCP Tools\n")
|
||||
sb.WriteString("These are the external tools and services you can use:\n")
|
||||
for _, mcp := range robot.Config.Resources.MCP {
|
||||
if len(mcp.Tools) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- **%s**: %s\n", mcp.ID, strings.Join(mcp.Tools, ", ")))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- **%s**: all tools available\n", mcp.ID))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Available Knowledge Base
|
||||
if robot.Config.KB != nil && len(robot.Config.KB.Collections) > 0 {
|
||||
if !hasContent {
|
||||
sb.WriteString("## Available Resources\n\n")
|
||||
hasContent = true
|
||||
}
|
||||
sb.WriteString("### Knowledge Base\n")
|
||||
sb.WriteString("You have access to these knowledge collections:\n")
|
||||
for _, collection := range robot.Config.KB.Collections {
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", collection))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Available Database Models
|
||||
if robot.Config.DB != nil && len(robot.Config.DB.Models) > 0 {
|
||||
if !hasContent {
|
||||
sb.WriteString("## Available Resources\n\n")
|
||||
hasContent = true
|
||||
}
|
||||
sb.WriteString("### Database\n")
|
||||
sb.WriteString("You can query these database models:\n")
|
||||
for _, model := range robot.Config.DB.Models {
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", model))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if !hasContent {
|
||||
return ""
|
||||
}
|
||||
|
||||
sb.WriteString("**Important**: Only plan goals and tasks that can be accomplished with the above resources.\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatInspirationReport formats InspirationReport as user message content
|
||||
// Used by P1 (Goals) phase when trigger is Clock
|
||||
func (f *InputFormatter) FormatInspirationReport(report *robottypes.InspirationReport) string {
|
||||
if report == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Clock context summary (if available)
|
||||
if report.Clock != nil {
|
||||
sb.WriteString("## Time Context\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Time**: %s %s\n", report.Clock.DayOfWeek, report.Clock.Now.Format("15:04")))
|
||||
sb.WriteString(fmt.Sprintf("- **Date**: %d/%d/%d\n", report.Clock.Year, report.Clock.Month, report.Clock.DayOfMonth))
|
||||
|
||||
// Add relevant time markers
|
||||
var markers []string
|
||||
if report.Clock.IsWeekend {
|
||||
markers = append(markers, "Weekend")
|
||||
}
|
||||
if report.Clock.IsMonthStart {
|
||||
markers = append(markers, "Month Start")
|
||||
}
|
||||
if report.Clock.IsMonthEnd {
|
||||
markers = append(markers, "Month End")
|
||||
}
|
||||
if report.Clock.IsQuarterEnd {
|
||||
markers = append(markers, "Quarter End")
|
||||
}
|
||||
if len(markers) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- **Markers**: %s\n", strings.Join(markers, ", ")))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Inspiration content
|
||||
if report.Content != "" {
|
||||
sb.WriteString("## Inspiration Report\n\n")
|
||||
sb.WriteString(report.Content)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatTriggerInput formats TriggerInput as user message content
|
||||
// Used by P1 (Goals) phase when trigger is Human or Event
|
||||
func (f *InputFormatter) FormatTriggerInput(input *robottypes.TriggerInput) string {
|
||||
if input == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Human intervention
|
||||
if input.Action != "" {
|
||||
sb.WriteString("## Human Intervention\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Action**: %s\n", input.Action))
|
||||
if input.UserID != "" {
|
||||
sb.WriteString(fmt.Sprintf("- **User**: %s\n", input.UserID))
|
||||
}
|
||||
|
||||
// Messages
|
||||
if len(input.Messages) > 0 {
|
||||
sb.WriteString("\n### User Input\n\n")
|
||||
for _, msg := range input.Messages {
|
||||
if content, ok := msg.Content.(string); ok {
|
||||
sb.WriteString(content)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Event trigger
|
||||
if input.Source != "" {
|
||||
sb.WriteString("## Event Trigger\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Source**: %s\n", input.Source))
|
||||
sb.WriteString(fmt.Sprintf("- **Event Type**: %s\n", input.EventType))
|
||||
|
||||
// Event data
|
||||
if input.Data != nil {
|
||||
sb.WriteString("\n### Event Data\n\n")
|
||||
sb.WriteString("```json\n")
|
||||
if data, err := json.MarshalIndent(input.Data, "", " "); err == nil {
|
||||
sb.WriteString(string(data))
|
||||
}
|
||||
sb.WriteString("\n```\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// FormatGoals formats Goals as user message content
|
||||
// Used by P2 (Tasks) phase
|
||||
func (f *InputFormatter) FormatGoals(goals *robottypes.Goals, robot *robottypes.Robot) string {
|
||||
if goals == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Goals content
|
||||
sb.WriteString("## Goals\n\n")
|
||||
sb.WriteString(goals.Content)
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Available resources - reuse FormatAvailableResources for consistency
|
||||
resourcesContent := f.FormatAvailableResources(robot)
|
||||
if resourcesContent != "" {
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(resourcesContent)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatTasks formats Tasks as user message content
|
||||
// Used by P3 (Run) phase
|
||||
func (f *InputFormatter) FormatTasks(tasks []robottypes.Task) string {
|
||||
if len(tasks) == 0 {
|
||||
return "No tasks to execute."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Tasks to Execute\n\n")
|
||||
for i, task := range tasks {
|
||||
sb.WriteString(fmt.Sprintf("### Task %d: %s\n\n", i+1, task.ID))
|
||||
sb.WriteString(fmt.Sprintf("- **Goal Reference**: %s\n", task.GoalRef))
|
||||
sb.WriteString(fmt.Sprintf("- **Source**: %s\n", task.Source))
|
||||
sb.WriteString(fmt.Sprintf("- **Executor**: %s (%s)\n", task.ExecutorID, task.ExecutorType))
|
||||
|
||||
// Task content
|
||||
if len(task.Messages) > 0 {
|
||||
sb.WriteString("\n**Instructions**:\n")
|
||||
for _, msg := range task.Messages {
|
||||
if content, ok := msg.Content.(string); ok {
|
||||
sb.WriteString(content)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arguments
|
||||
if len(task.Args) > 0 {
|
||||
sb.WriteString("\n**Arguments**:\n")
|
||||
if args, err := json.MarshalIndent(task.Args, "", " "); err == nil {
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(string(args))
|
||||
sb.WriteString("\n```\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatTaskResults formats TaskResults as user message content
|
||||
// Used by P4 (Delivery) and P5 (Learning) phases
|
||||
func (f *InputFormatter) FormatTaskResults(results []robottypes.TaskResult) string {
|
||||
if len(results) == 0 {
|
||||
return "No task results."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Task Results\n\n")
|
||||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
validatedPassedCount := 0
|
||||
validatedTotalCount := 0
|
||||
|
||||
for _, result := range results {
|
||||
if result.Success {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
if result.Validation != nil {
|
||||
validatedTotalCount++
|
||||
if result.Validation.Passed {
|
||||
validatedPassedCount++
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### Task: %s\n\n", result.TaskID))
|
||||
if result.Success {
|
||||
sb.WriteString("- **Status**: ✓ Success\n")
|
||||
} else {
|
||||
sb.WriteString("- **Status**: ✗ Failed\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- **Duration**: %dms\n", result.Duration))
|
||||
|
||||
// Validation result (P3)
|
||||
if result.Validation != nil {
|
||||
if result.Validation.Passed {
|
||||
sb.WriteString(fmt.Sprintf("- **Validation**: ✓ Passed (score: %.2f)\n", result.Validation.Score))
|
||||
} else {
|
||||
sb.WriteString("- **Validation**: ✗ Failed\n")
|
||||
if len(result.Validation.Issues) > 0 {
|
||||
sb.WriteString(" - Issues:\n")
|
||||
for _, issue := range result.Validation.Issues {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", issue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
if result.Output != nil {
|
||||
sb.WriteString("\n**Output**:\n")
|
||||
if output, err := json.MarshalIndent(result.Output, "", " "); err == nil {
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(string(output))
|
||||
sb.WriteString("\n```\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%v\n", result.Output))
|
||||
}
|
||||
}
|
||||
|
||||
// Error
|
||||
if result.Error != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Error**: %s\n", result.Error))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Summary
|
||||
sb.WriteString(fmt.Sprintf("## Summary\n\n- Total: %d tasks\n- Success: %d\n- Failed: %d\n- Validated: %d/%d\n",
|
||||
len(results), successCount, failCount, validatedPassedCount, validatedTotalCount))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatExecutionSummary formats the entire execution for P5 (Learning) phase
|
||||
func (f *InputFormatter) FormatExecutionSummary(exec *robottypes.Execution) string {
|
||||
if exec == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Execution metadata
|
||||
sb.WriteString("## Execution Summary\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **ID**: %s\n", exec.ID))
|
||||
sb.WriteString(fmt.Sprintf("- **Trigger**: %s\n", exec.TriggerType))
|
||||
sb.WriteString(fmt.Sprintf("- **Status**: %s\n", exec.Status))
|
||||
sb.WriteString(fmt.Sprintf("- **Start Time**: %s\n", exec.StartTime.Format("2006-01-02 15:04:05")))
|
||||
if exec.EndTime != nil {
|
||||
sb.WriteString(fmt.Sprintf("- **End Time**: %s\n", exec.EndTime.Format("2006-01-02 15:04:05")))
|
||||
duration := exec.EndTime.Sub(exec.StartTime)
|
||||
sb.WriteString(fmt.Sprintf("- **Duration**: %s\n", duration.String()))
|
||||
}
|
||||
if exec.Error != "" {
|
||||
sb.WriteString(fmt.Sprintf("- **Error**: %s\n", exec.Error))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Inspiration (P0)
|
||||
if exec.Inspiration != nil && exec.Inspiration.Content != "" {
|
||||
sb.WriteString("## Inspiration (P0)\n\n")
|
||||
sb.WriteString(exec.Inspiration.Content)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Goals (P1)
|
||||
if exec.Goals != nil && exec.Goals.Content != "" {
|
||||
sb.WriteString("## Goals (P1)\n\n")
|
||||
sb.WriteString(exec.Goals.Content)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Tasks (P2)
|
||||
if len(exec.Tasks) > 0 {
|
||||
sb.WriteString("## Tasks (P2)\n\n")
|
||||
for i, task := range exec.Tasks {
|
||||
sb.WriteString(fmt.Sprintf("%d. [%s] %s (executor: %s)\n",
|
||||
i+1, task.Status, task.ID, task.ExecutorID))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Results (P3)
|
||||
if len(exec.Results) > 0 {
|
||||
sb.WriteString("## Results (P3)\n\n")
|
||||
for _, result := range exec.Results {
|
||||
status := "✓"
|
||||
if !result.Success {
|
||||
status = "✗"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- %s %s (%dms)\n", status, result.TaskID, result.Duration))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Delivery (P4)
|
||||
if exec.Delivery != nil {
|
||||
sb.WriteString("## Delivery (P4)\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Type**: %s\n", exec.Delivery.Type))
|
||||
if exec.Delivery.Success {
|
||||
sb.WriteString("- **Status**: ✓ Success\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- **Status**: ✗ Failed (%s)\n", exec.Delivery.Error))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BuildMessages is a convenience method to build messages array from content
|
||||
func (f *InputFormatter) BuildMessages(userContent string) []agentcontext.Message {
|
||||
return []agentcontext.Message{
|
||||
{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: userContent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildMessagesWithSystem builds messages array with system and user content
|
||||
func (f *InputFormatter) BuildMessagesWithSystem(systemContent, userContent string) []agentcontext.Message {
|
||||
return []agentcontext.Message{
|
||||
{
|
||||
Role: agentcontext.RoleSystem,
|
||||
Content: systemContent,
|
||||
},
|
||||
{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: userContent,
|
||||
},
|
||||
}
|
||||
}
|
||||
532
agent/robot/executor/standard/input_test.go
Normal file
532
agent/robot/executor/standard/input_test.go
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
package standard_test
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// InputFormatter Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestInputFormatterFormatClockContext(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("formats clock context with all fields", func(t *testing.T) {
|
||||
now := time.Date(2024, 1, 15, 9, 30, 0, 0, time.UTC)
|
||||
clock := types.NewClockContext(now, "UTC")
|
||||
|
||||
result := formatter.FormatClockContext(clock, nil)
|
||||
|
||||
assert.Contains(t, result, "## Current Time Context")
|
||||
assert.Contains(t, result, "2024-01-15 09:30:00")
|
||||
assert.Contains(t, result, "Monday")
|
||||
assert.Contains(t, result, "UTC")
|
||||
assert.Contains(t, result, "### Time Markers")
|
||||
})
|
||||
|
||||
t.Run("includes robot identity when provided", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
clock := types.NewClockContext(now, "UTC")
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-robot",
|
||||
Config: &types.Config{
|
||||
Identity: &types.Identity{
|
||||
Role: "Sales Analyst",
|
||||
Duties: []string{"Analyze sales data", "Generate reports"},
|
||||
Rules: []string{"Be accurate", "Be concise"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatClockContext(clock, robot)
|
||||
|
||||
assert.Contains(t, result, "## Robot Identity")
|
||||
assert.Contains(t, result, "Sales Analyst")
|
||||
assert.Contains(t, result, "Analyze sales data")
|
||||
assert.Contains(t, result, "Be accurate")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil clock", func(t *testing.T) {
|
||||
result := formatter.FormatClockContext(nil, nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("marks weekend correctly", func(t *testing.T) {
|
||||
// Saturday
|
||||
saturday := time.Date(2024, 1, 13, 10, 0, 0, 0, time.UTC)
|
||||
clock := types.NewClockContext(saturday, "UTC")
|
||||
|
||||
result := formatter.FormatClockContext(clock, nil)
|
||||
|
||||
assert.Contains(t, result, "✓ Weekend")
|
||||
})
|
||||
|
||||
t.Run("marks month start correctly", func(t *testing.T) {
|
||||
// 2nd of month
|
||||
monthStart := time.Date(2024, 1, 2, 10, 0, 0, 0, time.UTC)
|
||||
clock := types.NewClockContext(monthStart, "UTC")
|
||||
|
||||
result := formatter.FormatClockContext(clock, nil)
|
||||
|
||||
assert.Contains(t, result, "✓ Month Start")
|
||||
})
|
||||
|
||||
t.Run("marks month end correctly", func(t *testing.T) {
|
||||
// 30th of January (last 3 days)
|
||||
monthEnd := time.Date(2024, 1, 30, 10, 0, 0, 0, time.UTC)
|
||||
clock := types.NewClockContext(monthEnd, "UTC")
|
||||
|
||||
result := formatter.FormatClockContext(clock, nil)
|
||||
|
||||
assert.Contains(t, result, "✓ Month End")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatInspirationReport(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("formats inspiration report with clock", func(t *testing.T) {
|
||||
now := time.Date(2024, 1, 15, 9, 30, 0, 0, time.UTC)
|
||||
clock := types.NewClockContext(now, "UTC")
|
||||
report := &types.InspirationReport{
|
||||
Clock: clock,
|
||||
Content: "Today is a good day to analyze sales data.",
|
||||
}
|
||||
|
||||
result := formatter.FormatInspirationReport(report)
|
||||
|
||||
assert.Contains(t, result, "## Time Context")
|
||||
assert.Contains(t, result, "Monday")
|
||||
assert.Contains(t, result, "## Inspiration Report")
|
||||
assert.Contains(t, result, "analyze sales data")
|
||||
})
|
||||
|
||||
t.Run("formats inspiration report without clock", func(t *testing.T) {
|
||||
report := &types.InspirationReport{
|
||||
Content: "Focus on quarterly review.",
|
||||
}
|
||||
|
||||
result := formatter.FormatInspirationReport(report)
|
||||
|
||||
assert.NotContains(t, result, "## Time Context")
|
||||
assert.Contains(t, result, "## Inspiration Report")
|
||||
assert.Contains(t, result, "quarterly review")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil report", func(t *testing.T) {
|
||||
result := formatter.FormatInspirationReport(nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatAvailableResources(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("formats all resource types", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-robot",
|
||||
Config: &types.Config{
|
||||
Resources: &types.Resources{
|
||||
Agents: []string{"data-analyst", "chart-gen", "report-writer"},
|
||||
MCP: []types.MCPConfig{
|
||||
{ID: "database", Tools: []string{"query", "insert"}},
|
||||
{ID: "email", Tools: []string{}}, // all tools
|
||||
},
|
||||
},
|
||||
KB: &types.KB{
|
||||
Collections: []string{"sales-policies", "products"},
|
||||
},
|
||||
DB: &types.DB{
|
||||
Models: []string{"sales", "customers", "orders"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatAvailableResources(robot)
|
||||
|
||||
// Check structure
|
||||
assert.Contains(t, result, "## Available Resources")
|
||||
|
||||
// Check agents
|
||||
assert.Contains(t, result, "### Agents")
|
||||
assert.Contains(t, result, "data-analyst")
|
||||
assert.Contains(t, result, "chart-gen")
|
||||
assert.Contains(t, result, "report-writer")
|
||||
|
||||
// Check MCP tools
|
||||
assert.Contains(t, result, "### MCP Tools")
|
||||
assert.Contains(t, result, "database")
|
||||
assert.Contains(t, result, "query, insert")
|
||||
assert.Contains(t, result, "email")
|
||||
assert.Contains(t, result, "all tools available")
|
||||
|
||||
// Check KB
|
||||
assert.Contains(t, result, "### Knowledge Base")
|
||||
assert.Contains(t, result, "sales-policies")
|
||||
assert.Contains(t, result, "products")
|
||||
|
||||
// Check DB
|
||||
assert.Contains(t, result, "### Database")
|
||||
assert.Contains(t, result, "sales")
|
||||
assert.Contains(t, result, "customers")
|
||||
assert.Contains(t, result, "orders")
|
||||
|
||||
// Check important note
|
||||
assert.Contains(t, result, "Only plan goals and tasks that can be accomplished")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil robot", func(t *testing.T) {
|
||||
result := formatter.FormatAvailableResources(nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("returns empty for robot without config", func(t *testing.T) {
|
||||
robot := &types.Robot{MemberID: "test"}
|
||||
result := formatter.FormatAvailableResources(robot)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("returns empty for robot without resources", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
MemberID: "test",
|
||||
Config: &types.Config{},
|
||||
}
|
||||
result := formatter.FormatAvailableResources(robot)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("handles partial resources", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
MemberID: "test",
|
||||
Config: &types.Config{
|
||||
Resources: &types.Resources{
|
||||
Agents: []string{"single-agent"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatAvailableResources(robot)
|
||||
|
||||
assert.Contains(t, result, "## Available Resources")
|
||||
assert.Contains(t, result, "### Agents")
|
||||
assert.Contains(t, result, "single-agent")
|
||||
assert.NotContains(t, result, "### MCP Tools")
|
||||
assert.NotContains(t, result, "### Knowledge Base")
|
||||
assert.NotContains(t, result, "### Database")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatTriggerInput(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("formats human intervention", func(t *testing.T) {
|
||||
input := &types.TriggerInput{
|
||||
Action: "task.add",
|
||||
UserID: "user-123",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Please add a task to review Q4 sales"},
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatTriggerInput(input)
|
||||
|
||||
assert.Contains(t, result, "## Human Intervention")
|
||||
assert.Contains(t, result, "task.add")
|
||||
assert.Contains(t, result, "user-123")
|
||||
assert.Contains(t, result, "### User Input")
|
||||
assert.Contains(t, result, "review Q4 sales")
|
||||
})
|
||||
|
||||
t.Run("formats event trigger", func(t *testing.T) {
|
||||
input := &types.TriggerInput{
|
||||
Source: "webhook",
|
||||
EventType: "order.created",
|
||||
Data: map[string]interface{}{
|
||||
"order_id": "12345",
|
||||
"amount": 99.99,
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatTriggerInput(input)
|
||||
|
||||
assert.Contains(t, result, "## Event Trigger")
|
||||
assert.Contains(t, result, "webhook")
|
||||
assert.Contains(t, result, "order.created")
|
||||
assert.Contains(t, result, "### Event Data")
|
||||
assert.Contains(t, result, "order_id")
|
||||
assert.Contains(t, result, "12345")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil input", func(t *testing.T) {
|
||||
result := formatter.FormatTriggerInput(nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("returns empty for empty input", func(t *testing.T) {
|
||||
input := &types.TriggerInput{}
|
||||
result := formatter.FormatTriggerInput(input)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatGoals(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("formats goals with resources", func(t *testing.T) {
|
||||
goals := &types.Goals{
|
||||
Content: "1. Analyze sales data\n2. Generate report\n3. Send to stakeholders",
|
||||
}
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-robot",
|
||||
Config: &types.Config{
|
||||
Resources: &types.Resources{
|
||||
Agents: []string{"data-analyzer", "report-generator"},
|
||||
MCP: []types.MCPConfig{
|
||||
{ID: "database", Tools: []string{"query", "insert"}},
|
||||
{ID: "email"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatGoals(goals, robot)
|
||||
|
||||
assert.Contains(t, result, "## Goals")
|
||||
assert.Contains(t, result, "Analyze sales data")
|
||||
assert.Contains(t, result, "## Available Resources")
|
||||
assert.Contains(t, result, "### Agents")
|
||||
assert.Contains(t, result, "data-analyzer")
|
||||
assert.Contains(t, result, "### MCP Tools")
|
||||
assert.Contains(t, result, "database")
|
||||
assert.Contains(t, result, "query, insert")
|
||||
assert.Contains(t, result, "email")
|
||||
assert.Contains(t, result, "all tools available")
|
||||
})
|
||||
|
||||
t.Run("formats goals without robot", func(t *testing.T) {
|
||||
goals := &types.Goals{
|
||||
Content: "Complete the task.",
|
||||
}
|
||||
|
||||
result := formatter.FormatGoals(goals, nil)
|
||||
|
||||
assert.Contains(t, result, "## Goals")
|
||||
assert.Contains(t, result, "Complete the task")
|
||||
assert.NotContains(t, result, "## Available Resources")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil goals", func(t *testing.T) {
|
||||
result := formatter.FormatGoals(nil, nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatTasks(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("formats multiple tasks", func(t *testing.T) {
|
||||
tasks := []types.Task{
|
||||
{
|
||||
ID: "task-1",
|
||||
GoalRef: "goal-1",
|
||||
Source: types.TaskSourceAuto,
|
||||
ExecutorType: types.ExecutorMCP,
|
||||
ExecutorID: "database.query",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Query sales data for Q4"},
|
||||
},
|
||||
Args: []any{"sales", "Q4"},
|
||||
},
|
||||
{
|
||||
ID: "task-2",
|
||||
GoalRef: "goal-1",
|
||||
Source: types.TaskSourceAuto,
|
||||
ExecutorType: types.ExecutorAssistant,
|
||||
ExecutorID: "report-generator",
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatTasks(tasks)
|
||||
|
||||
assert.Contains(t, result, "## Tasks to Execute")
|
||||
assert.Contains(t, result, "### Task 1: task-1")
|
||||
assert.Contains(t, result, "goal-1")
|
||||
assert.Contains(t, result, "database.query")
|
||||
assert.Contains(t, result, "**Instructions**")
|
||||
assert.Contains(t, result, "Query sales data")
|
||||
assert.Contains(t, result, "**Arguments**")
|
||||
assert.Contains(t, result, "### Task 2: task-2")
|
||||
assert.Contains(t, result, "report-generator")
|
||||
})
|
||||
|
||||
t.Run("returns message for empty tasks", func(t *testing.T) {
|
||||
result := formatter.FormatTasks(nil)
|
||||
assert.Equal(t, "No tasks to execute.", result)
|
||||
|
||||
result = formatter.FormatTasks([]types.Task{})
|
||||
assert.Equal(t, "No tasks to execute.", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatTaskResults(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("formats task results with summary", func(t *testing.T) {
|
||||
results := []types.TaskResult{
|
||||
{
|
||||
TaskID: "task-1",
|
||||
Success: true,
|
||||
Duration: 150,
|
||||
Validation: &types.ValidationResult{
|
||||
Passed: true,
|
||||
Score: 0.95,
|
||||
},
|
||||
Output: map[string]interface{}{"rows": 100},
|
||||
},
|
||||
{
|
||||
TaskID: "task-2",
|
||||
Success: false,
|
||||
Duration: 50,
|
||||
Validation: &types.ValidationResult{
|
||||
Passed: false,
|
||||
Issues: []string{"Connection timeout"},
|
||||
},
|
||||
Error: "Connection timeout",
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatTaskResults(results)
|
||||
|
||||
assert.Contains(t, result, "## Task Results")
|
||||
assert.Contains(t, result, "### Task: task-1")
|
||||
assert.Contains(t, result, "✓ Success")
|
||||
assert.Contains(t, result, "150ms")
|
||||
assert.Contains(t, result, "**Validation**: ✓ Passed")
|
||||
assert.Contains(t, result, "score: 0.95")
|
||||
assert.Contains(t, result, "**Output**")
|
||||
assert.Contains(t, result, "### Task: task-2")
|
||||
assert.Contains(t, result, "✗ Failed")
|
||||
assert.Contains(t, result, "**Validation**: ✗ Failed")
|
||||
assert.Contains(t, result, "Connection timeout")
|
||||
assert.Contains(t, result, "## Summary")
|
||||
assert.Contains(t, result, "Total: 2 tasks")
|
||||
assert.Contains(t, result, "Success: 1")
|
||||
assert.Contains(t, result, "Failed: 1")
|
||||
assert.Contains(t, result, "Validated: 1/2")
|
||||
})
|
||||
|
||||
t.Run("returns message for empty results", func(t *testing.T) {
|
||||
result := formatter.FormatTaskResults(nil)
|
||||
assert.Equal(t, "No task results.", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatExecutionSummary(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("formats complete execution summary", func(t *testing.T) {
|
||||
startTime := time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC)
|
||||
endTime := time.Date(2024, 1, 15, 9, 5, 0, 0, time.UTC)
|
||||
exec := &types.Execution{
|
||||
ID: "exec-123",
|
||||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecCompleted,
|
||||
StartTime: startTime,
|
||||
EndTime: &endTime,
|
||||
Inspiration: &types.InspirationReport{
|
||||
Content: "Morning analysis suggests high activity.",
|
||||
},
|
||||
Goals: &types.Goals{
|
||||
Content: "1. Review data\n2. Generate report",
|
||||
},
|
||||
Tasks: []types.Task{
|
||||
{ID: "t1", Status: types.TaskCompleted, ExecutorID: "db.query"},
|
||||
{ID: "t2", Status: types.TaskCompleted, ExecutorID: "report.gen"},
|
||||
},
|
||||
Results: []types.TaskResult{
|
||||
{TaskID: "t1", Success: true, Duration: 100},
|
||||
{TaskID: "t2", Success: true, Duration: 200},
|
||||
},
|
||||
Delivery: &types.DeliveryResult{
|
||||
Type: types.DeliveryEmail,
|
||||
Success: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatExecutionSummary(exec)
|
||||
|
||||
assert.Contains(t, result, "## Execution Summary")
|
||||
assert.Contains(t, result, "exec-123")
|
||||
assert.Contains(t, result, "clock")
|
||||
assert.Contains(t, result, "completed")
|
||||
assert.Contains(t, result, "**Duration**:")
|
||||
assert.Contains(t, result, "## Inspiration (P0)")
|
||||
assert.Contains(t, result, "Morning analysis")
|
||||
assert.Contains(t, result, "## Goals (P1)")
|
||||
assert.Contains(t, result, "Review data")
|
||||
assert.Contains(t, result, "## Tasks (P2)")
|
||||
assert.Contains(t, result, "db.query")
|
||||
assert.Contains(t, result, "## Results (P3)")
|
||||
assert.Contains(t, result, "✓ t1")
|
||||
assert.Contains(t, result, "## Delivery (P4)")
|
||||
assert.Contains(t, result, "email")
|
||||
})
|
||||
|
||||
t.Run("formats execution with error", func(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
exec := &types.Execution{
|
||||
ID: "exec-456",
|
||||
TriggerType: types.TriggerHuman,
|
||||
Status: types.ExecFailed,
|
||||
StartTime: startTime,
|
||||
Error: "Task execution failed",
|
||||
}
|
||||
|
||||
result := formatter.FormatExecutionSummary(exec)
|
||||
|
||||
assert.Contains(t, result, "exec-456")
|
||||
assert.Contains(t, result, "failed")
|
||||
assert.Contains(t, result, "**Error**: Task execution failed")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil execution", func(t *testing.T) {
|
||||
result := formatter.FormatExecutionSummary(nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterBuildMessages(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("builds user message", func(t *testing.T) {
|
||||
msgs := formatter.BuildMessages("Hello, world!")
|
||||
|
||||
require.Len(t, msgs, 1)
|
||||
assert.Equal(t, agentcontext.RoleUser, msgs[0].Role)
|
||||
assert.Equal(t, "Hello, world!", msgs[0].Content)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterBuildMessagesWithSystem(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
t.Run("builds system and user messages", func(t *testing.T) {
|
||||
msgs := formatter.BuildMessagesWithSystem(
|
||||
"You are a helpful assistant.",
|
||||
"What is the weather?",
|
||||
)
|
||||
|
||||
require.Len(t, msgs, 2)
|
||||
assert.Equal(t, agentcontext.RoleSystem, msgs[0].Role)
|
||||
assert.Equal(t, "You are a helpful assistant.", msgs[0].Content)
|
||||
assert.Equal(t, agentcontext.RoleUser, msgs[1].Role)
|
||||
assert.Equal(t, "What is the weather?", msgs[1].Content)
|
||||
})
|
||||
}
|
||||
70
agent/robot/executor/standard/inspiration.go
Normal file
70
agent/robot/executor/standard/inspiration.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunInspiration executes P0: Inspiration phase
|
||||
// Calls the Inspiration Agent to generate daily briefing
|
||||
//
|
||||
// Input:
|
||||
// - ClockContext from trigger input or current time
|
||||
// - Robot identity and resources
|
||||
//
|
||||
// Output:
|
||||
// - InspirationReport with markdown content
|
||||
func (e *Executor) RunInspiration(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
||||
// Get robot for identity and resources
|
||||
robot := exec.GetRobot()
|
||||
if robot == nil {
|
||||
return fmt.Errorf("robot not found in execution")
|
||||
}
|
||||
|
||||
// Build clock context from trigger input or current time
|
||||
var clock *robottypes.ClockContext
|
||||
if exec.Input != nil && exec.Input.Clock != nil {
|
||||
clock = exec.Input.Clock
|
||||
} else {
|
||||
clock = robottypes.NewClockContext(time.Now(), "")
|
||||
}
|
||||
|
||||
// Get agent ID for inspiration phase
|
||||
agentID := "__yao.inspiration" // default
|
||||
if robot.Config != nil && robot.Config.Resources != nil {
|
||||
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseInspiration)
|
||||
}
|
||||
|
||||
// Build prompt using InputFormatter
|
||||
formatter := NewInputFormatter()
|
||||
userContent := formatter.FormatClockContext(clock, robot)
|
||||
|
||||
// Add available resources - critical for generating achievable insights
|
||||
resourcesContent := formatter.FormatAvailableResources(robot)
|
||||
if resourcesContent != "" {
|
||||
userContent += "\n\n" + resourcesContent
|
||||
}
|
||||
|
||||
// Call agent
|
||||
caller := NewAgentCaller()
|
||||
result, err := caller.CallWithMessages(ctx, agentID, userContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspiration agent (%s) call failed: %w", agentID, err)
|
||||
}
|
||||
|
||||
// Parse response - get markdown content
|
||||
content := result.GetText()
|
||||
if content == "" {
|
||||
return fmt.Errorf("inspiration agent (%s) returned empty response", agentID)
|
||||
}
|
||||
|
||||
// Build InspirationReport
|
||||
exec.Inspiration = &robottypes.InspirationReport{
|
||||
Clock: clock,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
369
agent/robot/executor/standard/inspiration_test.go
Normal file
369
agent/robot/executor/standard/inspiration_test.go
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
package standard_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/robot/executor/standard"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// P0 Inspiration Phase Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestRunInspirationBasic(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 inspiration report with clock context", func(t *testing.T) {
|
||||
// Create robot with inspiration agent configured
|
||||
robot := createTestRobot(t, "robot.inspiration")
|
||||
|
||||
// Create executor and execution
|
||||
exec := createTestExecution(robot, types.TriggerClock)
|
||||
|
||||
// Run inspiration phase
|
||||
e := standard.New()
|
||||
err := e.RunInspiration(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec.Inspiration)
|
||||
assert.NotEmpty(t, exec.Inspiration.Content)
|
||||
assert.NotNil(t, exec.Inspiration.Clock)
|
||||
})
|
||||
|
||||
t.Run("includes expected markdown sections", func(t *testing.T) {
|
||||
robot := createTestRobot(t, "robot.inspiration")
|
||||
exec := createTestExecution(robot, types.TriggerClock)
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunInspiration(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
content := exec.Inspiration.Content
|
||||
|
||||
// Verify expected sections in markdown output
|
||||
// Note: LLM output is non-deterministic, so we check for likely sections
|
||||
hasSection := strings.Contains(content, "##") ||
|
||||
strings.Contains(content, "Summary") ||
|
||||
strings.Contains(content, "Highlight") ||
|
||||
strings.Contains(content, "Recommend")
|
||||
|
||||
assert.True(t, hasSection, "should contain markdown sections, got: %s", content)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunInspirationClockContext(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("uses clock from trigger input", func(t *testing.T) {
|
||||
robot := createTestRobot(t, "robot.inspiration")
|
||||
exec := createTestExecution(robot, types.TriggerClock)
|
||||
|
||||
// Set specific clock context
|
||||
specificTime := time.Date(2024, 12, 31, 17, 0, 0, 0, time.UTC)
|
||||
exec.Input = &types.TriggerInput{
|
||||
Clock: types.NewClockContext(specificTime, "UTC"),
|
||||
}
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunInspiration(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec.Inspiration.Clock)
|
||||
|
||||
// Clock should match input
|
||||
assert.Equal(t, specificTime.Year(), exec.Inspiration.Clock.Year)
|
||||
assert.Equal(t, int(specificTime.Month()), exec.Inspiration.Clock.Month)
|
||||
assert.Equal(t, specificTime.Day(), exec.Inspiration.Clock.DayOfMonth)
|
||||
})
|
||||
|
||||
t.Run("creates clock context when not provided", func(t *testing.T) {
|
||||
robot := createTestRobot(t, "robot.inspiration")
|
||||
exec := createTestExecution(robot, types.TriggerClock)
|
||||
exec.Input = nil // No input
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunInspiration(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec.Inspiration.Clock)
|
||||
|
||||
// Clock should be current time (approximately)
|
||||
now := time.Now()
|
||||
assert.Equal(t, now.Year(), exec.Inspiration.Clock.Year)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunInspirationRobotIdentity(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("robot identity influences output", func(t *testing.T) {
|
||||
// Create robot with specific identity
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-robot-1",
|
||||
TeamID: "test-team-1",
|
||||
DisplayName: "Sales Assistant",
|
||||
Config: &types.Config{
|
||||
Identity: &types.Identity{
|
||||
Role: "Sales Assistant",
|
||||
Duties: []string{"Track sales metrics", "Prepare weekly reports"},
|
||||
Rules: []string{"Focus on actionable insights"},
|
||||
},
|
||||
Resources: &types.Resources{
|
||||
Phases: map[types.Phase]string{
|
||||
types.PhaseInspiration: "robot.inspiration",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exec := createTestExecution(robot, types.TriggerClock)
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunInspiration(ctx, exec, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, exec.Inspiration.Content)
|
||||
|
||||
// The content should be influenced by robot identity
|
||||
// (exact content varies due to LLM non-determinism)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunInspirationErrorHandling(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.RunInspiration(ctx, exec, nil)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "robot not found")
|
||||
})
|
||||
|
||||
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.PhaseInspiration: "non.existent.agent",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
exec := createTestExecution(robot, types.TriggerClock)
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunInspiration(ctx, exec, nil)
|
||||
|
||||
// Real AgentCaller returns error for non-existent agent
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "call failed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunInspirationWithDefaultAgent(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("uses default agent when not configured", func(t *testing.T) {
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-robot-1",
|
||||
TeamID: "test-team-1",
|
||||
Config: &types.Config{
|
||||
Identity: &types.Identity{Role: "Test Robot"},
|
||||
// No Resources configured - should use default __yao.inspiration
|
||||
},
|
||||
}
|
||||
exec := createTestExecution(robot, types.TriggerClock)
|
||||
|
||||
e := standard.New()
|
||||
err := e.RunInspiration(ctx, exec, nil)
|
||||
|
||||
// This will fail if __yao.inspiration doesn't exist
|
||||
// In test environment, we expect it to fail with "agent not found"
|
||||
// In production, it would use the default agent
|
||||
if err != nil {
|
||||
assert.Contains(t, err.Error(), "call failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// InputFormatter Tests for P0
|
||||
// ============================================================================
|
||||
|
||||
func TestInputFormatterClockContext(t *testing.T) {
|
||||
t.Run("formats clock context correctly", func(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
// Create a specific clock context
|
||||
clock := types.NewClockContext(
|
||||
time.Date(2024, 12, 31, 17, 30, 0, 0, time.UTC),
|
||||
"UTC",
|
||||
)
|
||||
|
||||
robot := &types.Robot{
|
||||
Config: &types.Config{
|
||||
Identity: &types.Identity{
|
||||
Role: "Sales Assistant",
|
||||
Duties: []string{"Track metrics", "Send reports"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
content := formatter.FormatClockContext(clock, robot)
|
||||
|
||||
// Verify time context
|
||||
assert.Contains(t, content, "Current Time Context")
|
||||
assert.Contains(t, content, "2024")
|
||||
assert.Contains(t, content, "12")
|
||||
assert.Contains(t, content, "31")
|
||||
assert.Contains(t, content, "Tuesday") // Dec 31, 2024 is Tuesday
|
||||
|
||||
// Verify robot identity
|
||||
assert.Contains(t, content, "Robot Identity")
|
||||
assert.Contains(t, content, "Sales Assistant")
|
||||
assert.Contains(t, content, "Track metrics")
|
||||
})
|
||||
|
||||
t.Run("handles nil clock", func(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
content := formatter.FormatClockContext(nil, nil)
|
||||
assert.Empty(t, content)
|
||||
})
|
||||
|
||||
t.Run("handles nil robot", func(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
clock := types.NewClockContext(time.Now(), "")
|
||||
content := formatter.FormatClockContext(clock, nil)
|
||||
|
||||
// Should have time context but no robot identity
|
||||
assert.Contains(t, content, "Current Time Context")
|
||||
assert.NotContains(t, content, "Robot Identity")
|
||||
})
|
||||
|
||||
t.Run("includes time markers", func(t *testing.T) {
|
||||
formatter := standard.NewInputFormatter()
|
||||
|
||||
// Create a weekend + month start clock context
|
||||
// Jan 1, 2028 is Saturday (weekend + month start)
|
||||
clock := types.NewClockContext(
|
||||
time.Date(2028, 1, 1, 10, 0, 0, 0, time.UTC),
|
||||
"UTC",
|
||||
)
|
||||
|
||||
content := formatter.FormatClockContext(clock, nil)
|
||||
|
||||
assert.Contains(t, content, "Weekend")
|
||||
assert.Contains(t, content, "Month Start")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
// createTestRobot creates a test robot with specified inspiration agent
|
||||
// Includes available expert agents so the Inspiration Agent knows what resources are available
|
||||
//
|
||||
// Note: The agent IDs listed in Resources.Agents must exist in yao-dev-app/assistants/experts/
|
||||
// Current available experts: data-analyst, summarizer, text-writer, web-reader
|
||||
func createTestRobot(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.PhaseInspiration: agentID,
|
||||
},
|
||||
// Available expert agents that can be delegated to
|
||||
// These IDs correspond to assistants in yao-dev-app/assistants/experts/
|
||||
Agents: []string{
|
||||
"experts.data-analyst", // Data analysis and insights
|
||||
"experts.summarizer", // Content summarization
|
||||
"experts.text-writer", // Report and document generation
|
||||
"experts.web-reader", // Web content extraction
|
||||
},
|
||||
},
|
||||
// Knowledge base collections (if any)
|
||||
KB: &types.KB{
|
||||
Collections: []string{"test-knowledge"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createTestExecution creates a test execution for a robot
|
||||
func createTestExecution(robot *types.Robot, trigger types.TriggerType) *types.Execution {
|
||||
exec := &types.Execution{
|
||||
ID: "test-exec-1",
|
||||
MemberID: robot.MemberID,
|
||||
TeamID: robot.TeamID,
|
||||
TriggerType: trigger,
|
||||
StartTime: time.Now(),
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseInspiration,
|
||||
Input: &types.TriggerInput{
|
||||
Clock: types.NewClockContext(time.Now(), ""),
|
||||
},
|
||||
}
|
||||
exec.SetRobot(robot)
|
||||
return exec
|
||||
}
|
||||
32
agent/robot/executor/standard/learning.go
Normal file
32
agent/robot/executor/standard/learning.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunLearning executes P5: Learning phase
|
||||
// Extracts learnings and saves to knowledge base
|
||||
//
|
||||
// Input:
|
||||
// - Execution summary (all phases)
|
||||
//
|
||||
// Output:
|
||||
// - LearningEntry list with extracted knowledge
|
||||
//
|
||||
// Learning Types:
|
||||
// - LearnExecution: Execution patterns
|
||||
// - LearnTask: Task-specific insights
|
||||
// - LearnError: Error patterns for improvement
|
||||
//
|
||||
// TODO: Implement real learning extraction
|
||||
func (e *Executor) RunLearning(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
||||
e.simulateStreamDelay()
|
||||
|
||||
exec.Learning = []robottypes.LearningEntry{
|
||||
{
|
||||
Type: robottypes.LearnExecution,
|
||||
Content: "Completed daily tasks successfully",
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
39
agent/robot/executor/standard/run.go
Normal file
39
agent/robot/executor/standard/run.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunExecution executes P3: Run phase
|
||||
// Executes each task using the appropriate executor (Assistant, Process, or Function)
|
||||
//
|
||||
// Input:
|
||||
// - Tasks (from P2)
|
||||
//
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
agent/robot/executor/standard/tasks.go
Normal file
32
agent/robot/executor/standard/tasks.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunTasks executes P2: Tasks phase
|
||||
// Calls the Tasks Agent to break down goals into executable tasks
|
||||
//
|
||||
// Input:
|
||||
// - Goals (from P1)
|
||||
// - Available resources (Agents, MCP tools)
|
||||
//
|
||||
// Output:
|
||||
// - List of Task objects with executor assignments
|
||||
//
|
||||
// TODO: Implement real Agent call
|
||||
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,
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunTasks executes P2: Tasks phase
|
||||
//
|
||||
// Reads Goals markdown and breaks into executable tasks.
|
||||
// Each task specifies executor type (assistant/mcp/process) and arguments.
|
||||
//
|
||||
// Implementation (TODO Phase 6):
|
||||
// 1. Build prompt with Goals
|
||||
// 2. Call Task Planning Agent via Assistant.Stream()
|
||||
// 3. Parse response to []Task (structured)
|
||||
func (e *Executor) RunTasks(_ *types.Context, exec *types.Execution, _ interface{}) error {
|
||||
// TODO (Phase 6): Replace with real Agent call
|
||||
// agentID := robot.Config.Resources.GetPhaseAgent(types.PhaseTasks)
|
||||
// messages := buildTasksMessages(exec.Goals, robot)
|
||||
// response, err := callAgentStream(ctx, agentID, messages)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// exec.Tasks = parseTasks(response)
|
||||
|
||||
// Simulate Agent Stream delay
|
||||
e.simulateStreamDelay()
|
||||
|
||||
// Generate mock tasks
|
||||
exec.Tasks = []types.Task{
|
||||
{
|
||||
ID: "task_1",
|
||||
GoalRef: "Goal 1",
|
||||
Source: types.TaskSourceAuto,
|
||||
ExecutorType: types.ExecutorAssistant,
|
||||
ExecutorID: "data-analyst",
|
||||
Status: types.TaskPending,
|
||||
Order: 0,
|
||||
},
|
||||
{
|
||||
ID: "task_2",
|
||||
GoalRef: "Goal 2",
|
||||
Source: types.TaskSourceAuto,
|
||||
ExecutorType: types.ExecutorAssistant,
|
||||
ExecutorID: "report-writer",
|
||||
Status: types.TaskPending,
|
||||
Order: 1,
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
33
agent/robot/executor/types/helpers.go
Normal file
33
agent/robot/executor/types/helpers.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// BuildTriggerInput builds TriggerInput from trigger data
|
||||
// Shared helper used by all executor implementations
|
||||
func BuildTriggerInput(trigger robottypes.TriggerType, data interface{}) *robottypes.TriggerInput {
|
||||
input := &robottypes.TriggerInput{}
|
||||
|
||||
switch trigger {
|
||||
case robottypes.TriggerClock:
|
||||
input.Clock = robottypes.NewClockContext(time.Now(), "")
|
||||
|
||||
case robottypes.TriggerHuman:
|
||||
if req, ok := data.(*robottypes.InterveneRequest); ok {
|
||||
input.Action = req.Action
|
||||
input.Messages = req.Messages
|
||||
}
|
||||
|
||||
case robottypes.TriggerEvent:
|
||||
if req, ok := data.(*robottypes.EventRequest); ok {
|
||||
input.Source = robottypes.EventSource(req.Source)
|
||||
input.EventType = req.EventType
|
||||
input.Data = req.Data
|
||||
}
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
132
agent/robot/executor/types/types.go
Normal file
132
agent/robot/executor/types/types.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// Executor defines the interface for robot phase execution
|
||||
// Different implementations provide different execution strategies:
|
||||
// - Standard: Real Agent calls with full phase execution
|
||||
// - DryRun: Plan-only mode, simulates execution without Agent calls
|
||||
// - Sandbox: Isolated execution with resource limits and safety controls
|
||||
type Executor interface {
|
||||
// Execute runs a robot through all applicable phases
|
||||
// ctx: Execution context with auth and logging
|
||||
// robot: Robot configuration and state
|
||||
// trigger: What triggered this execution (clock, human, event)
|
||||
// data: Trigger-specific data (human input, event payload, etc.)
|
||||
// Returns: Execution record with all phase outputs
|
||||
Execute(ctx *robottypes.Context, robot *robottypes.Robot, trigger robottypes.TriggerType, data interface{}) (*robottypes.Execution, error)
|
||||
|
||||
// Metrics and control
|
||||
ExecCount() int // Total execution count
|
||||
CurrentCount() int // Currently running execution count
|
||||
Reset() // Reset counters (for testing)
|
||||
}
|
||||
|
||||
// PhaseExecutor defines the interface for individual phase execution
|
||||
// Used internally by Executor implementations
|
||||
type PhaseExecutor interface {
|
||||
// RunInspiration executes P0: Inspiration phase
|
||||
RunInspiration(ctx *robottypes.Context, exec *robottypes.Execution, data interface{}) error
|
||||
|
||||
// RunGoals executes P1: Goals phase
|
||||
RunGoals(ctx *robottypes.Context, exec *robottypes.Execution, data interface{}) error
|
||||
|
||||
// RunTasks executes P2: Tasks phase
|
||||
RunTasks(ctx *robottypes.Context, exec *robottypes.Execution, data interface{}) error
|
||||
|
||||
// RunExecution executes P3: Run phase (task execution)
|
||||
RunExecution(ctx *robottypes.Context, exec *robottypes.Execution, data interface{}) error
|
||||
|
||||
// RunDelivery executes P4: Delivery phase
|
||||
RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, data interface{}) error
|
||||
|
||||
// RunLearning executes P5: Learning phase
|
||||
RunLearning(ctx *robottypes.Context, exec *robottypes.Execution, data interface{}) error
|
||||
}
|
||||
|
||||
// Config holds common executor configuration
|
||||
type Config struct {
|
||||
// SkipJobIntegration skips job system integration (for testing)
|
||||
SkipJobIntegration bool
|
||||
|
||||
// OnPhaseStart callback when a phase starts
|
||||
OnPhaseStart func(phase robottypes.Phase)
|
||||
|
||||
// OnPhaseEnd callback when a phase ends
|
||||
OnPhaseEnd func(phase robottypes.Phase)
|
||||
}
|
||||
|
||||
// DryRunConfig holds dry-run specific configuration
|
||||
type DryRunConfig struct {
|
||||
Config
|
||||
|
||||
// Delay simulates execution delay for each phase
|
||||
Delay time.Duration
|
||||
|
||||
// OnStart callback on execution start
|
||||
OnStart func()
|
||||
|
||||
// OnEnd callback on execution end
|
||||
OnEnd func()
|
||||
}
|
||||
|
||||
// SandboxConfig holds sandbox specific configuration
|
||||
//
|
||||
// ⚠️ NOT IMPLEMENTED: These settings are placeholders for future
|
||||
// container-based isolation. True sandbox requires infrastructure support
|
||||
// (Docker/gVisor/Firecracker). Current implementation behaves like DryRun.
|
||||
type SandboxConfig struct {
|
||||
Config
|
||||
|
||||
// MaxDuration limits total execution time
|
||||
MaxDuration time.Duration
|
||||
|
||||
// MaxMemory limits memory usage (bytes) - requires container runtime
|
||||
MaxMemory int64
|
||||
|
||||
// AllowedAgents restricts which agents can be called
|
||||
AllowedAgents []string
|
||||
|
||||
// AllowedTools restricts which tools can be used
|
||||
AllowedTools []string
|
||||
|
||||
// NetworkAccess controls network access - requires container networking
|
||||
NetworkAccess bool
|
||||
|
||||
// FileAccess controls file system access - requires container filesystem
|
||||
FileAccess bool
|
||||
}
|
||||
|
||||
// Mode represents the executor mode
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeStandard Mode = "standard" // Real Agent execution (production)
|
||||
ModeDryRun Mode = "dryrun" // Simulated execution (testing/demo)
|
||||
ModeSandbox Mode = "sandbox" // Container-isolated execution (NOT IMPLEMENTED)
|
||||
)
|
||||
|
||||
// Setting holds executor settings from configuration
|
||||
type Setting struct {
|
||||
Mode Mode `json:"mode,omitempty" yaml:"mode,omitempty"` // Executor mode
|
||||
MaxDuration time.Duration `json:"max_duration,omitempty" yaml:"max_duration,omitempty"` // Max execution time
|
||||
MaxMemory int64 `json:"max_memory,omitempty" yaml:"max_memory,omitempty"` // Max memory (bytes)
|
||||
AllowedAgents []string `json:"allowed_agents,omitempty" yaml:"allowed_agents,omitempty"` // Allowed agent IDs
|
||||
NetworkAccess bool `json:"network_access,omitempty" yaml:"network_access,omitempty"` // Allow network
|
||||
FileAccess bool `json:"file_access,omitempty" yaml:"file_access,omitempty"` // Allow file system
|
||||
}
|
||||
|
||||
// DefaultSetting returns default executor settings
|
||||
func DefaultSetting() *Setting {
|
||||
return &Setting{
|
||||
Mode: ModeStandard,
|
||||
MaxDuration: 30 * time.Minute,
|
||||
MaxMemory: 512 * 1024 * 1024, // 512MB
|
||||
NetworkAccess: true,
|
||||
FileAccess: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -14,12 +14,25 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/xun/capsule"
|
||||
"github.com/yaoapp/yao/agent/robot/executor"
|
||||
"github.com/yaoapp/yao/agent/robot/manager"
|
||||
"github.com/yaoapp/yao/agent/robot/pool"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// createClockTestManager creates a manager with mock executor for clock tests
|
||||
func createClockTestManager(t *testing.T, tickInterval time.Duration, workerSize, queueSize int) (*manager.Manager, *executor.DryRunExecutor) {
|
||||
exec := executor.NewDryRunWithDelay(0)
|
||||
config := &manager.Config{
|
||||
TickInterval: tickInterval,
|
||||
PoolConfig: &pool.Config{WorkerSize: workerSize, QueueSize: queueSize},
|
||||
Executor: exec,
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
return m, exec
|
||||
}
|
||||
|
||||
// ==================== Times Mode Tests ====================
|
||||
|
||||
// TestIntegrationClockTimesMode tests the times mode clock trigger
|
||||
|
|
@ -35,6 +48,9 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
defer cleanupIntegrationRobots(t)
|
||||
|
||||
t.Run("triggers at configured time", func(t *testing.T) {
|
||||
// Clean up before each subtest to ensure isolation
|
||||
cleanupIntegrationRobots(t)
|
||||
|
||||
setupClockTestRobot(t, "robot_integ_clock_times1", "team_integ_clock", map[string]interface{}{
|
||||
"mode": "times",
|
||||
"times": []string{"09:00", "14:00", "17:00"},
|
||||
|
|
@ -42,11 +58,7 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
"tz": "Asia/Shanghai",
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -56,7 +68,7 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
robot := m.Cache().Get("robot_integ_clock_times1")
|
||||
require.NotNil(t, robot, "Robot should be loaded into cache")
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at 09:00 on Wednesday
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
|
|
@ -67,10 +79,13 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
assert.GreaterOrEqual(t, m.Executor().ExecCount(), 1, "Should trigger at 09:00")
|
||||
assert.GreaterOrEqual(t, exec.ExecCount(), 1, "Should trigger at 09:00")
|
||||
})
|
||||
|
||||
t.Run("does not trigger at non-configured time", func(t *testing.T) {
|
||||
// Clean up before each subtest to ensure isolation
|
||||
cleanupIntegrationRobots(t)
|
||||
|
||||
setupClockTestRobot(t, "robot_integ_clock_times2", "team_integ_clock", map[string]interface{}{
|
||||
"mode": "times",
|
||||
"times": []string{"09:00", "14:00"},
|
||||
|
|
@ -78,17 +93,13 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
"tz": "Asia/Shanghai",
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at 10:30 (not configured)
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
|
|
@ -99,10 +110,13 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
assert.Equal(t, 0, m.Executor().ExecCount(), "Should not trigger at non-configured time")
|
||||
assert.Equal(t, 0, exec.ExecCount(), "Should not trigger at non-configured time")
|
||||
})
|
||||
|
||||
t.Run("does not trigger on non-configured day", func(t *testing.T) {
|
||||
// Clean up before each subtest to ensure isolation
|
||||
cleanupIntegrationRobots(t)
|
||||
|
||||
setupClockTestRobot(t, "robot_integ_clock_times3", "team_integ_clock", map[string]interface{}{
|
||||
"mode": "times",
|
||||
"times": []string{"09:00"},
|
||||
|
|
@ -110,17 +124,13 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
"tz": "Asia/Shanghai",
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at 09:00 on Saturday (not configured)
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
|
|
@ -131,10 +141,13 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
assert.Equal(t, 0, m.Executor().ExecCount(), "Should not trigger on Saturday")
|
||||
assert.Equal(t, 0, exec.ExecCount(), "Should not trigger on Saturday")
|
||||
})
|
||||
|
||||
t.Run("wildcard days matches all days", func(t *testing.T) {
|
||||
// Clean up before each subtest to ensure isolation
|
||||
cleanupIntegrationRobots(t)
|
||||
|
||||
setupClockTestRobot(t, "robot_integ_clock_times4", "team_integ_clock", map[string]interface{}{
|
||||
"mode": "times",
|
||||
"times": []string{"09:00"},
|
||||
|
|
@ -142,17 +155,13 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
"tz": "Asia/Shanghai",
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at 09:00 on Saturday
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
|
|
@ -163,10 +172,13 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
assert.GreaterOrEqual(t, m.Executor().ExecCount(), 1, "Should trigger on Saturday with wildcard days")
|
||||
assert.GreaterOrEqual(t, exec.ExecCount(), 1, "Should trigger on Saturday with wildcard days")
|
||||
})
|
||||
|
||||
t.Run("dedup prevents double trigger in same minute", func(t *testing.T) {
|
||||
// Clean up before each subtest to ensure isolation
|
||||
cleanupIntegrationRobots(t)
|
||||
|
||||
setupClockTestRobot(t, "robot_integ_clock_times5", "team_integ_clock", map[string]interface{}{
|
||||
"mode": "times",
|
||||
"times": []string{"09:00"},
|
||||
|
|
@ -174,17 +186,13 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
"tz": "Asia/Shanghai",
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
|
@ -194,7 +202,7 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
err = m.Tick(ctx, now1)
|
||||
assert.NoError(t, err)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
firstCount := m.Executor().ExecCount()
|
||||
firstCount := exec.ExecCount()
|
||||
assert.GreaterOrEqual(t, firstCount, 1, "First tick should trigger")
|
||||
|
||||
// Second tick at 09:00:30 (same minute)
|
||||
|
|
@ -204,7 +212,7 @@ func TestIntegrationClockTimesMode(t *testing.T) {
|
|||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Should not trigger again in same minute
|
||||
assert.Equal(t, firstCount, m.Executor().ExecCount(), "Should not trigger twice in same minute")
|
||||
assert.Equal(t, firstCount, exec.ExecCount(), "Should not trigger twice in same minute")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -223,22 +231,21 @@ func TestIntegrationClockIntervalMode(t *testing.T) {
|
|||
defer cleanupIntegrationRobots(t)
|
||||
|
||||
t.Run("triggers on first run", func(t *testing.T) {
|
||||
// Clean up before each subtest to ensure isolation
|
||||
cleanupIntegrationRobots(t)
|
||||
|
||||
setupClockTestRobot(t, "robot_integ_clock_interval1", "team_integ_clock", map[string]interface{}{
|
||||
"mode": "interval",
|
||||
"every": "30m",
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
now := time.Now()
|
||||
|
|
@ -246,26 +253,25 @@ func TestIntegrationClockIntervalMode(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
assert.GreaterOrEqual(t, m.Executor().ExecCount(), 1, "Should trigger on first run")
|
||||
assert.GreaterOrEqual(t, exec.ExecCount(), 1, "Should trigger on first run")
|
||||
})
|
||||
|
||||
t.Run("triggers after interval passed", func(t *testing.T) {
|
||||
// Clean up before each subtest to ensure isolation
|
||||
cleanupIntegrationRobots(t)
|
||||
|
||||
setupClockTestRobot(t, "robot_integ_clock_interval2", "team_integ_clock", map[string]interface{}{
|
||||
"mode": "interval",
|
||||
"every": "100ms", // Short interval for testing
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 50 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 50*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
|
|
@ -274,7 +280,7 @@ func TestIntegrationClockIntervalMode(t *testing.T) {
|
|||
err = m.Tick(ctx, now1)
|
||||
assert.NoError(t, err)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
firstCount := m.Executor().ExecCount()
|
||||
firstCount := exec.ExecCount()
|
||||
assert.GreaterOrEqual(t, firstCount, 1, "First tick should trigger")
|
||||
|
||||
// Wait for interval to pass
|
||||
|
|
@ -287,26 +293,25 @@ func TestIntegrationClockIntervalMode(t *testing.T) {
|
|||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Should have triggered again
|
||||
assert.Greater(t, m.Executor().ExecCount(), firstCount, "Should trigger again after interval")
|
||||
assert.Greater(t, exec.ExecCount(), firstCount, "Should trigger again after interval")
|
||||
})
|
||||
|
||||
t.Run("does not trigger before interval passed", func(t *testing.T) {
|
||||
// Clean up before each subtest to ensure isolation
|
||||
cleanupIntegrationRobots(t)
|
||||
|
||||
setupClockTestRobot(t, "robot_integ_clock_interval3", "team_integ_clock", map[string]interface{}{
|
||||
"mode": "interval",
|
||||
"every": "1h", // Long interval
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
|
|
@ -315,7 +320,7 @@ func TestIntegrationClockIntervalMode(t *testing.T) {
|
|||
err = m.Tick(ctx, now1)
|
||||
assert.NoError(t, err)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
firstCount := m.Executor().ExecCount()
|
||||
firstCount := exec.ExecCount()
|
||||
assert.GreaterOrEqual(t, firstCount, 1, "First tick should trigger")
|
||||
|
||||
// Second tick immediately (interval not passed)
|
||||
|
|
@ -325,7 +330,7 @@ func TestIntegrationClockIntervalMode(t *testing.T) {
|
|||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Should not trigger again
|
||||
assert.Equal(t, firstCount, m.Executor().ExecCount(), "Should not trigger before interval")
|
||||
assert.Equal(t, firstCount, exec.ExecCount(), "Should not trigger before interval")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -349,24 +354,20 @@ func TestIntegrationClockDaemonMode(t *testing.T) {
|
|||
"timeout": "5m",
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
err = m.Tick(ctx, time.Now())
|
||||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
assert.GreaterOrEqual(t, m.Executor().ExecCount(), 1, "Daemon should trigger when idle")
|
||||
assert.GreaterOrEqual(t, exec.ExecCount(), 1, "Daemon should trigger when idle")
|
||||
})
|
||||
|
||||
t.Run("respects quota limit", func(t *testing.T) {
|
||||
|
|
@ -378,17 +379,13 @@ func TestIntegrationClockDaemonMode(t *testing.T) {
|
|||
},
|
||||
1, 5, 5) // Max=1, Queue=5
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 50 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 5, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 50*time.Millisecond, 5, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
|
|
@ -430,17 +427,13 @@ func TestIntegrationClockTimezone(t *testing.T) {
|
|||
"tz": "Asia/Shanghai",
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
|
|
@ -452,7 +445,7 @@ func TestIntegrationClockTimezone(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
assert.GreaterOrEqual(t, m.Executor().ExecCount(), 1, "Should trigger at 09:00 Shanghai time")
|
||||
assert.GreaterOrEqual(t, exec.ExecCount(), 1, "Should trigger at 09:00 Shanghai time")
|
||||
})
|
||||
|
||||
t.Run("different timezone same UTC time", func(t *testing.T) {
|
||||
|
|
@ -472,17 +465,13 @@ func TestIntegrationClockTimezone(t *testing.T) {
|
|||
"tz": "America/New_York",
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
m, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
||||
m.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
|
||||
|
|
@ -494,7 +483,7 @@ func TestIntegrationClockTimezone(t *testing.T) {
|
|||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Only Shanghai robot should trigger
|
||||
execCount := m.Executor().ExecCount()
|
||||
execCount := exec.ExecCount()
|
||||
assert.GreaterOrEqual(t, execCount, 1, "Shanghai robot should trigger")
|
||||
// New York robot should not trigger (it's 20:00 in NY)
|
||||
})
|
||||
|
|
@ -548,17 +537,13 @@ func TestIntegrationClockEdgeCases(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
mgr := manager.NewWithConfig(config)
|
||||
mgr, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err = mgr.Start()
|
||||
require.NoError(t, err)
|
||||
defer mgr.Stop()
|
||||
|
||||
mgr.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at matching time
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
|
|
@ -569,7 +554,7 @@ func TestIntegrationClockEdgeCases(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
assert.Equal(t, 0, mgr.Executor().ExecCount(), "Clock disabled robot should not trigger")
|
||||
assert.Equal(t, 0, exec.ExecCount(), "Clock disabled robot should not trigger")
|
||||
})
|
||||
|
||||
t.Run("paused robot is skipped", func(t *testing.T) {
|
||||
|
|
@ -606,17 +591,13 @@ func TestIntegrationClockEdgeCases(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
mgr := manager.NewWithConfig(config)
|
||||
mgr, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err = mgr.Start()
|
||||
require.NoError(t, err)
|
||||
defer mgr.Stop()
|
||||
|
||||
mgr.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
// Trigger at matching time
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
|
|
@ -627,7 +608,7 @@ func TestIntegrationClockEdgeCases(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
assert.Equal(t, 0, mgr.Executor().ExecCount(), "Paused robot should not trigger")
|
||||
assert.Equal(t, 0, exec.ExecCount(), "Paused robot should not trigger")
|
||||
})
|
||||
|
||||
t.Run("robot without clock config is skipped", func(t *testing.T) {
|
||||
|
|
@ -660,24 +641,20 @@ func TestIntegrationClockEdgeCases(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 3, QueueSize: 20},
|
||||
}
|
||||
mgr := manager.NewWithConfig(config)
|
||||
mgr, exec := createClockTestManager(t, 100*time.Millisecond, 3, 20)
|
||||
|
||||
err = mgr.Start()
|
||||
require.NoError(t, err)
|
||||
defer mgr.Stop()
|
||||
|
||||
mgr.Executor().Reset()
|
||||
exec.Reset()
|
||||
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
err = mgr.Tick(ctx, time.Now())
|
||||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
assert.Equal(t, 0, mgr.Executor().ExecCount(), "Robot without clock config should not trigger")
|
||||
assert.Equal(t, 0, exec.ExecCount(), "Robot without clock config should not trigger")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ func TestIntegrationConcurrentExecution(t *testing.T) {
|
|||
var maxConcurrent int32
|
||||
var currentConcurrent int32
|
||||
|
||||
exec := executor.NewWithCallback(100*time.Millisecond,
|
||||
exec := executor.NewDryRunWithCallbacks(100*time.Millisecond,
|
||||
func() {
|
||||
curr := atomic.AddInt32(¤tConcurrent, 1)
|
||||
for {
|
||||
|
|
@ -109,7 +109,7 @@ func TestIntegrationConcurrentExecution(t *testing.T) {
|
|||
t.Run("same robot multiple triggers", func(t *testing.T) {
|
||||
setupConcurrentTestRobot(t, "robot_integ_conc_same", "team_integ_conc", 3, 20)
|
||||
|
||||
exec := executor.NewWithDelay(50 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(50 * time.Millisecond)
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
|
|
@ -160,7 +160,7 @@ func TestIntegrationQuotaEnforcement(t *testing.T) {
|
|||
var maxConcurrent int32
|
||||
var currentConcurrent int32
|
||||
|
||||
exec := executor.NewWithCallback(200*time.Millisecond,
|
||||
exec := executor.NewDryRunWithCallbacks(200*time.Millisecond,
|
||||
func() {
|
||||
curr := atomic.AddInt32(¤tConcurrent, 1)
|
||||
for {
|
||||
|
|
@ -210,7 +210,7 @@ func TestIntegrationQuotaEnforcement(t *testing.T) {
|
|||
// Create robot with Max=1, Queue=3
|
||||
setupConcurrentTestRobot(t, "robot_integ_quota_queue", "team_integ_quota", 1, 3)
|
||||
|
||||
exec := executor.NewWithDelay(300 * time.Millisecond) // Slow execution
|
||||
exec := executor.NewDryRunWithDelay(300 * time.Millisecond) // Slow execution
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
|
|
@ -337,7 +337,7 @@ func TestIntegrationGlobalPoolLimit(t *testing.T) {
|
|||
var maxConcurrent int32
|
||||
var currentConcurrent int32
|
||||
|
||||
exec := executor.NewWithCallback(200*time.Millisecond,
|
||||
exec := executor.NewDryRunWithCallbacks(200*time.Millisecond,
|
||||
func() {
|
||||
curr := atomic.AddInt32(¤tConcurrent, 1)
|
||||
for {
|
||||
|
|
@ -391,7 +391,7 @@ func TestIntegrationGlobalPoolLimit(t *testing.T) {
|
|||
setupConcurrentTestRobot(t, memberID, "team_integ_pool", 5, 20)
|
||||
}
|
||||
|
||||
exec := executor.NewWithDelay(500 * time.Millisecond) // Slow execution
|
||||
exec := executor.NewDryRunWithDelay(500 * time.Millisecond) // Slow execution
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
|
|
|
|||
|
|
@ -233,22 +233,21 @@ func TestIntegrationPhaseProgression(t *testing.T) {
|
|||
|
||||
// Track phases executed
|
||||
phasesExecuted := make([]types.Phase, 0)
|
||||
exec := executor.NewWithConfig(executor.Config{
|
||||
SkipJobIntegration: true,
|
||||
OnPhaseStart: func(phase types.Phase) {
|
||||
phasesExecuted = append(phasesExecuted, phase)
|
||||
exec := executor.NewDryRunWithConfig(executor.DryRunConfig{
|
||||
Config: executor.Config{
|
||||
OnPhaseStart: func(phase types.Phase) {
|
||||
phasesExecuted = append(phasesExecuted, phase)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 20},
|
||||
Executor: exec,
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
||||
// Replace executor
|
||||
m.Pool().SetExecutor(exec)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
|
@ -272,22 +271,21 @@ func TestIntegrationPhaseProgression(t *testing.T) {
|
|||
|
||||
// Track phases executed
|
||||
phasesExecuted := make([]types.Phase, 0)
|
||||
exec := executor.NewWithConfig(executor.Config{
|
||||
SkipJobIntegration: true,
|
||||
OnPhaseStart: func(phase types.Phase) {
|
||||
phasesExecuted = append(phasesExecuted, phase)
|
||||
exec := executor.NewDryRunWithConfig(executor.DryRunConfig{
|
||||
Config: executor.Config{
|
||||
OnPhaseStart: func(phase types.Phase) {
|
||||
phasesExecuted = append(phasesExecuted, phase)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
config := &manager.Config{
|
||||
TickInterval: 100 * time.Millisecond,
|
||||
PoolConfig: &pool.Config{WorkerSize: 2, QueueSize: 20},
|
||||
Executor: exec,
|
||||
}
|
||||
m := manager.NewWithConfig(config)
|
||||
|
||||
// Replace executor
|
||||
m.Pool().SetExecutor(exec)
|
||||
|
||||
err := m.Start()
|
||||
require.NoError(t, err)
|
||||
defer m.Stop()
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ const (
|
|||
|
||||
// Config holds manager configuration
|
||||
type Config struct {
|
||||
TickInterval time.Duration // how often to check clock triggers (default: 1 minute)
|
||||
PoolConfig *pool.Config // worker pool configuration
|
||||
TickInterval time.Duration // how often to check clock triggers (default: 1 minute)
|
||||
PoolConfig *pool.Config // worker pool configuration
|
||||
Executor types.Executor // optional: custom executor (default: real executor)
|
||||
}
|
||||
|
||||
// DefaultConfig returns default manager configuration
|
||||
|
|
@ -38,7 +39,7 @@ type Manager struct {
|
|||
config *Config
|
||||
cache *cache.Cache
|
||||
pool *pool.Pool
|
||||
executor *executor.Executor
|
||||
executor types.Executor
|
||||
|
||||
// Execution control for pause/resume/stop
|
||||
execController *trigger.ExecutionController
|
||||
|
|
@ -75,12 +76,37 @@ func NewWithConfig(config *Config) *Manager {
|
|||
// Create components
|
||||
c := cache.New()
|
||||
p := pool.NewWithConfig(config.PoolConfig)
|
||||
e := executor.New()
|
||||
ec := trigger.NewExecutionController()
|
||||
|
||||
// Use custom executor if provided, otherwise create default
|
||||
var e types.Executor
|
||||
if config.Executor != nil {
|
||||
e = config.Executor
|
||||
} else {
|
||||
e = executor.New()
|
||||
}
|
||||
|
||||
// Wire up pool with executor
|
||||
p.SetExecutor(e)
|
||||
|
||||
// Create shared executor instances for each mode
|
||||
// These are reused across all executions to maintain accurate counters
|
||||
dryRunExecutor := executor.NewDryRun()
|
||||
|
||||
// Set executor factory for mode-specific executors
|
||||
p.SetExecutorFactory(func(mode types.ExecutorMode) types.Executor {
|
||||
switch mode {
|
||||
case types.ExecutorDryRun:
|
||||
return dryRunExecutor
|
||||
case types.ExecutorSandbox:
|
||||
// Sandbox not implemented, fall back to DryRun
|
||||
return dryRunExecutor
|
||||
default:
|
||||
// Standard mode or empty - use the configured executor
|
||||
return e
|
||||
}
|
||||
})
|
||||
|
||||
return &Manager{
|
||||
config: config,
|
||||
cache: c,
|
||||
|
|
@ -425,8 +451,11 @@ func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*t
|
|||
}, nil
|
||||
}
|
||||
|
||||
// Submit to pool
|
||||
execID, err := m.pool.Submit(ctx, robot, types.TriggerHuman, triggerInput)
|
||||
// Determine executor mode: request > robot config > default
|
||||
executorMode := m.resolveExecutorMode(req.ExecutorMode, robot)
|
||||
|
||||
// Submit to pool with executor mode
|
||||
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerHuman, triggerInput, executorMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -477,8 +506,11 @@ func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*typ
|
|||
// Build trigger input
|
||||
triggerInput := trigger.BuildEventInput(req)
|
||||
|
||||
// Submit to pool
|
||||
execID, err := m.pool.Submit(ctx, robot, types.TriggerEvent, triggerInput)
|
||||
// Determine executor mode: request > robot config > default
|
||||
executorMode := m.resolveExecutorMode(req.ExecutorMode, robot)
|
||||
|
||||
// Submit to pool with executor mode
|
||||
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerEvent, triggerInput, executorMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -529,6 +561,25 @@ func (m *Manager) ListExecutionsByMember(memberID string) []*trigger.ControlledE
|
|||
return m.execController.ListByMember(memberID)
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
// resolveExecutorMode determines the executor mode to use
|
||||
// Priority: request > robot config > default (standard)
|
||||
func (m *Manager) resolveExecutorMode(requestMode types.ExecutorMode, robot *types.Robot) types.ExecutorMode {
|
||||
// Request mode takes precedence
|
||||
if requestMode != "" && requestMode.IsValid() {
|
||||
return requestMode
|
||||
}
|
||||
|
||||
// Robot config mode
|
||||
if robot != nil && robot.Config != nil && robot.Config.Executor != nil {
|
||||
return robot.Config.Executor.GetMode()
|
||||
}
|
||||
|
||||
// Default: standard
|
||||
return types.ExecutorStandard
|
||||
}
|
||||
|
||||
// ==================== Getters for internal components ====================
|
||||
// These are exposed for testing and advanced use cases
|
||||
|
||||
|
|
@ -543,7 +594,7 @@ func (m *Manager) Pool() *pool.Pool {
|
|||
}
|
||||
|
||||
// Executor returns the internal executor
|
||||
func (m *Manager) Executor() *executor.Executor {
|
||||
func (m *Manager) Executor() types.Executor {
|
||||
return m.executor
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func TestPoolNoGoroutineLeak(t *testing.T) {
|
|||
baseline := getGoroutineCount()
|
||||
|
||||
// Create and start pool
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5,
|
||||
QueueSize: 100,
|
||||
|
|
@ -81,7 +81,7 @@ func TestPoolMultipleStartStop(t *testing.T) {
|
|||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
exec := executor.NewWithDelay(5 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(5 * time.Millisecond)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
|
|
@ -139,7 +139,7 @@ func TestPoolStopWithPendingJobs(t *testing.T) {
|
|||
baseline := getGoroutineCount()
|
||||
|
||||
// Use slow executor so jobs stay in queue
|
||||
exec := executor.NewWithDelay(500 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(500 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1, // only 1 worker
|
||||
QueueSize: 100,
|
||||
|
|
@ -170,7 +170,7 @@ func TestPoolConcurrentStartStop(t *testing.T) {
|
|||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5,
|
||||
QueueSize: 100,
|
||||
|
|
@ -223,7 +223,7 @@ func TestWorkerGoroutinesCleanup(t *testing.T) {
|
|||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(10 * time.Millisecond)
|
||||
|
||||
// Create pool with many workers
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
|
|
@ -253,7 +253,7 @@ func TestPoolLongRunningJobsNoLeak(t *testing.T) {
|
|||
time.Sleep(50 * time.Millisecond)
|
||||
baseline := getGoroutineCount()
|
||||
|
||||
exec := executor.NewWithDelay(200 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(200 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 3,
|
||||
QueueSize: 100,
|
||||
|
|
|
|||
|
|
@ -28,17 +28,21 @@ func DefaultConfig() *Config {
|
|||
}
|
||||
}
|
||||
|
||||
// ExecutorFactory creates an executor based on the mode
|
||||
type ExecutorFactory func(mode types.ExecutorMode) types.Executor
|
||||
|
||||
// Pool implements types.Pool interface
|
||||
// Manages a pool of workers that execute robot jobs from a priority queue
|
||||
type Pool struct {
|
||||
size int // number of workers
|
||||
queue *PriorityQueue // priority queue for pending jobs
|
||||
executor types.Executor // executor for running jobs
|
||||
workers []*Worker // worker goroutines
|
||||
running atomic.Int32 // number of currently running jobs
|
||||
wg sync.WaitGroup // wait group for graceful shutdown
|
||||
started bool // whether pool has been started
|
||||
mu sync.RWMutex // protects started flag
|
||||
size int // number of workers
|
||||
queue *PriorityQueue // priority queue for pending jobs
|
||||
executor types.Executor // default executor for running jobs
|
||||
executorFactory ExecutorFactory // optional: factory for mode-specific executors
|
||||
workers []*Worker // worker goroutines
|
||||
running atomic.Int32 // number of currently running jobs
|
||||
wg sync.WaitGroup // wait group for graceful shutdown
|
||||
started bool // whether pool has been started
|
||||
mu sync.RWMutex // protects started flag
|
||||
}
|
||||
|
||||
// New creates a new pool instance with default configuration
|
||||
|
|
@ -69,12 +73,29 @@ func NewWithConfig(config *Config) *Pool {
|
|||
}
|
||||
}
|
||||
|
||||
// SetExecutor sets the executor for the pool
|
||||
// SetExecutor sets the default executor for the pool
|
||||
// Must be called before Start()
|
||||
func (p *Pool) SetExecutor(executor types.Executor) {
|
||||
p.executor = executor
|
||||
}
|
||||
|
||||
// SetExecutorFactory sets the executor factory for mode-specific executors
|
||||
// If set, the factory is used to create executors based on ExecutorMode
|
||||
func (p *Pool) SetExecutorFactory(factory ExecutorFactory) {
|
||||
p.executorFactory = factory
|
||||
}
|
||||
|
||||
// GetExecutor returns the appropriate executor for the given mode
|
||||
// If factory is set and mode is specified, uses factory; otherwise uses default
|
||||
func (p *Pool) GetExecutor(mode types.ExecutorMode) types.Executor {
|
||||
// If factory is set and mode is specified, use factory
|
||||
if p.executorFactory != nil && mode != "" {
|
||||
return p.executorFactory(mode)
|
||||
}
|
||||
// Otherwise use default executor
|
||||
return p.executor
|
||||
}
|
||||
|
||||
// Start starts the worker pool
|
||||
func (p *Pool) Start() error {
|
||||
p.mu.Lock()
|
||||
|
|
@ -91,7 +112,7 @@ func (p *Pool) Start() error {
|
|||
// Create and start workers
|
||||
p.workers = make([]*Worker, p.size)
|
||||
for i := 0; i < p.size; i++ {
|
||||
worker := newWorker(i+1, p, p.executor, &p.wg)
|
||||
worker := newWorker(i+1, p, &p.wg)
|
||||
p.workers[i] = worker
|
||||
worker.start()
|
||||
}
|
||||
|
|
@ -125,6 +146,13 @@ func (p *Pool) Stop() error {
|
|||
// Submit submits a robot execution to the pool
|
||||
// Returns execution ID if successfully queued, error otherwise
|
||||
func (p *Pool) Submit(ctx *types.Context, robot *types.Robot, trigger types.TriggerType, data interface{}) (string, error) {
|
||||
return p.SubmitWithMode(ctx, robot, trigger, data, "")
|
||||
}
|
||||
|
||||
// SubmitWithMode submits a robot execution with specified executor mode
|
||||
// executorMode: optional, overrides robot's config if provided
|
||||
// Returns execution ID if successfully queued, error otherwise
|
||||
func (p *Pool) SubmitWithMode(ctx *types.Context, robot *types.Robot, trigger types.TriggerType, data interface{}, executorMode types.ExecutorMode) (string, error) {
|
||||
p.mu.RLock()
|
||||
if !p.started {
|
||||
p.mu.RUnlock()
|
||||
|
|
@ -138,10 +166,11 @@ func (p *Pool) Submit(ctx *types.Context, robot *types.Robot, trigger types.Trig
|
|||
|
||||
// Create queue item
|
||||
item := &QueueItem{
|
||||
Robot: robot,
|
||||
Ctx: ctx,
|
||||
Trigger: trigger,
|
||||
Data: data,
|
||||
Robot: robot,
|
||||
Ctx: ctx,
|
||||
Trigger: trigger,
|
||||
Data: data,
|
||||
ExecutorMode: executorMode,
|
||||
}
|
||||
|
||||
// Try to add to queue
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ func TestPoolSubmitNilRobot(t *testing.T) {
|
|||
|
||||
// TestPoolBasicExecution tests basic job execution
|
||||
func TestPoolBasicExecution(t *testing.T) {
|
||||
exec := executor.NewWithDelay(50 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(50 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5,
|
||||
QueueSize: 100,
|
||||
|
|
@ -125,7 +125,7 @@ func TestPoolBasicExecution(t *testing.T) {
|
|||
|
||||
// TestPoolConcurrencyLimit tests global worker limit
|
||||
func TestPoolConcurrencyLimit(t *testing.T) {
|
||||
exec := executor.NewWithDelay(200 * time.Millisecond) // longer delay
|
||||
exec := executor.NewDryRunWithDelay(200 * time.Millisecond) // longer delay
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 3, // only 3 workers
|
||||
QueueSize: 100,
|
||||
|
|
@ -170,7 +170,7 @@ func TestPoolConcurrencyLimit(t *testing.T) {
|
|||
|
||||
// TestRobotConcurrencyLimit tests per-robot concurrent execution limit
|
||||
func TestRobotConcurrencyLimit(t *testing.T) {
|
||||
exec := executor.NewWithDelay(100 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(100 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 10, // plenty of workers
|
||||
QueueSize: 100,
|
||||
|
|
@ -207,7 +207,7 @@ func TestRobotConcurrencyLimit(t *testing.T) {
|
|||
|
||||
// TestRobotQueueLimit tests per-robot queue limit
|
||||
func TestRobotQueueLimit(t *testing.T) {
|
||||
exec := executor.NewWithDelay(200 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(200 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 2,
|
||||
QueueSize: 100, // global queue is large
|
||||
|
|
@ -238,7 +238,7 @@ func TestRobotQueueLimit(t *testing.T) {
|
|||
|
||||
// TestGlobalQueueLimit tests global queue limit
|
||||
func TestGlobalQueueLimit(t *testing.T) {
|
||||
exec := executor.NewWithDelay(500 * time.Millisecond) // slow execution
|
||||
exec := executor.NewDryRunWithDelay(500 * time.Millisecond) // slow execution
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1, // only 1 worker
|
||||
QueueSize: 5, // small global queue
|
||||
|
|
@ -272,7 +272,7 @@ func TestGlobalQueueLimit(t *testing.T) {
|
|||
|
||||
// TestPriorityOrder tests that higher priority jobs execute first
|
||||
func TestPriorityOrder(t *testing.T) {
|
||||
exec := executor.NewWithDelay(50 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(50 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1, // single worker to ensure order
|
||||
QueueSize: 100,
|
||||
|
|
@ -302,7 +302,7 @@ func TestPriorityOrder(t *testing.T) {
|
|||
|
||||
// TestTriggerTypePriority tests that human triggers have higher priority than clock
|
||||
func TestTriggerTypePriority(t *testing.T) {
|
||||
exec := executor.NewWithDelay(50 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(50 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1, // single worker
|
||||
QueueSize: 100,
|
||||
|
|
@ -328,7 +328,7 @@ func TestTriggerTypePriority(t *testing.T) {
|
|||
|
||||
// TestMultipleRobotsFairness tests that multiple robots get fair access
|
||||
func TestMultipleRobotsFairness(t *testing.T) {
|
||||
exec := executor.NewWithDelay(30 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(30 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5,
|
||||
QueueSize: 100,
|
||||
|
|
@ -361,7 +361,7 @@ func TestMultipleRobotsFairness(t *testing.T) {
|
|||
|
||||
// TestGracefulShutdown tests that pool waits for running jobs on shutdown
|
||||
func TestGracefulShutdown(t *testing.T) {
|
||||
exec := executor.NewWithDelay(200 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(200 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 2,
|
||||
QueueSize: 10,
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ import (
|
|||
|
||||
// QueueItem represents a job waiting in the queue
|
||||
type QueueItem struct {
|
||||
Robot *types.Robot
|
||||
Ctx *types.Context
|
||||
Trigger types.TriggerType
|
||||
Data interface{}
|
||||
EnqueueTime time.Time
|
||||
Priority int // calculated priority for sorting
|
||||
Index int // index in heap (managed by container/heap)
|
||||
Robot *types.Robot
|
||||
Ctx *types.Context
|
||||
Trigger types.TriggerType
|
||||
Data interface{}
|
||||
ExecutorMode types.ExecutorMode // optional: override robot's executor mode
|
||||
EnqueueTime time.Time
|
||||
Priority int // calculated priority for sorting
|
||||
Index int // index in heap (managed by container/heap)
|
||||
}
|
||||
|
||||
// PriorityQueue implements a priority queue for robot executions
|
||||
|
|
|
|||
|
|
@ -12,17 +12,15 @@ import (
|
|||
type Worker struct {
|
||||
id int
|
||||
pool *Pool
|
||||
executor types.Executor
|
||||
stopChan chan struct{}
|
||||
wg *sync.WaitGroup
|
||||
}
|
||||
|
||||
// newWorker creates a new worker
|
||||
func newWorker(id int, pool *Pool, executor types.Executor, wg *sync.WaitGroup) *Worker {
|
||||
func newWorker(id int, pool *Pool, wg *sync.WaitGroup) *Worker {
|
||||
return &Worker{
|
||||
id: id,
|
||||
pool: pool,
|
||||
executor: executor,
|
||||
stopChan: make(chan struct{}),
|
||||
wg: wg,
|
||||
}
|
||||
|
|
@ -78,9 +76,12 @@ func (w *Worker) execute(item *QueueItem) {
|
|||
w.pool.incrementRunning()
|
||||
defer w.pool.decrementRunning()
|
||||
|
||||
// Get executor based on mode (uses factory if available, otherwise default)
|
||||
exec := w.pool.GetExecutor(item.ExecutorMode)
|
||||
|
||||
// Execute via Executor interface
|
||||
// Note: Executor.Execute() does atomic quota check via TryAcquireSlot()
|
||||
execution, err := w.executor.Execute(item.Ctx, item.Robot, item.Trigger, item.Data)
|
||||
execution, err := exec.Execute(item.Ctx, item.Robot, item.Trigger, item.Data)
|
||||
|
||||
if err != nil {
|
||||
// Check if it's a quota error (race condition - another worker got the slot)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
|
||||
// TestWorkerExecutesJob tests that worker executes a job from queue
|
||||
func TestWorkerExecutesJob(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
|
|
@ -39,7 +39,7 @@ func TestWorkerExecutesJob(t *testing.T) {
|
|||
|
||||
// TestWorkerMultipleJobs tests worker processes multiple jobs sequentially
|
||||
func TestWorkerMultipleJobs(t *testing.T) {
|
||||
exec := executor.NewWithDelay(20 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(20 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1, // single worker
|
||||
QueueSize: 10,
|
||||
|
|
@ -68,7 +68,7 @@ func TestWorkerMultipleJobs(t *testing.T) {
|
|||
// TestWorkerRespectsRobotQuota tests worker re-enqueues when robot quota is full
|
||||
func TestWorkerRespectsRobotQuota(t *testing.T) {
|
||||
// This test verifies that all jobs eventually complete even when robot quota limits concurrency
|
||||
exec := executor.NewWithDelay(100 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(100 * time.Millisecond)
|
||||
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 5, // multiple workers
|
||||
|
|
@ -98,7 +98,7 @@ func TestWorkerRespectsRobotQuota(t *testing.T) {
|
|||
|
||||
// TestWorkerReenqueueOnQuotaFull tests that jobs are re-enqueued when quota is full
|
||||
func TestWorkerReenqueueOnQuotaFull(t *testing.T) {
|
||||
exec := executor.NewWithDelay(100 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(100 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 3,
|
||||
QueueSize: 100,
|
||||
|
|
@ -132,7 +132,7 @@ func TestWorkersConcurrentExecution(t *testing.T) {
|
|||
var maxConcurrent int32
|
||||
var currentConcurrent int32
|
||||
|
||||
exec := executor.NewWithCallback(100*time.Millisecond, func() {
|
||||
exec := executor.NewDryRunWithCallbacks(100*time.Millisecond, func() {
|
||||
current := atomic.AddInt32(¤tConcurrent, 1)
|
||||
// Update max if current is higher
|
||||
for {
|
||||
|
|
@ -174,7 +174,7 @@ func TestWorkersDoNotExceedPoolSize(t *testing.T) {
|
|||
var currentConcurrent int32
|
||||
var mu sync.Mutex
|
||||
|
||||
exec := executor.NewWithCallback(50*time.Millisecond, func() {
|
||||
exec := executor.NewDryRunWithCallbacks(50*time.Millisecond, func() {
|
||||
mu.Lock()
|
||||
currentConcurrent++
|
||||
if currentConcurrent > maxConcurrent {
|
||||
|
|
@ -214,7 +214,7 @@ func TestWorkersDoNotExceedPoolSize(t *testing.T) {
|
|||
|
||||
// TestWorkerStopsGracefully tests worker stops when signaled
|
||||
func TestWorkerStopsGracefully(t *testing.T) {
|
||||
exec := executor.NewWithDelay(50 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(50 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 2,
|
||||
QueueSize: 10,
|
||||
|
|
@ -242,7 +242,7 @@ func TestWorkerStopsGracefully(t *testing.T) {
|
|||
|
||||
// TestWorkerCompletesCurrentJobOnStop tests worker completes current job before stopping
|
||||
func TestWorkerCompletesCurrentJobOnStop(t *testing.T) {
|
||||
exec := executor.NewWithDelay(100 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(100 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
|
|
@ -270,7 +270,7 @@ func TestWorkerCompletesCurrentJobOnStop(t *testing.T) {
|
|||
|
||||
// TestWorkerHandlesExecutorError tests worker continues after executor error
|
||||
func TestWorkerHandlesExecutorError(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
|
|
@ -299,7 +299,7 @@ func TestWorkerHandlesExecutorError(t *testing.T) {
|
|||
|
||||
// TestWorkerRunningCounterAccurate tests running counter is accurate
|
||||
func TestWorkerRunningCounterAccurate(t *testing.T) {
|
||||
exec := executor.NewWithDelay(100 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(100 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 3,
|
||||
QueueSize: 10,
|
||||
|
|
@ -330,7 +330,7 @@ func TestWorkerRunningCounterAccurate(t *testing.T) {
|
|||
|
||||
// TestWorkerRunningCounterDecrementsOnError tests running counter decrements on error
|
||||
func TestWorkerRunningCounterDecrementsOnError(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
|
|
@ -356,7 +356,7 @@ func TestWorkerRunningCounterDecrementsOnError(t *testing.T) {
|
|||
|
||||
// TestWorkerProcessesDifferentTriggers tests worker handles all trigger types
|
||||
func TestWorkerProcessesDifferentTriggers(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
|
|
@ -384,7 +384,7 @@ func TestWorkerProcessesDifferentTriggers(t *testing.T) {
|
|||
|
||||
// TestWorkerPollsQueuePeriodically tests worker polls queue at regular intervals
|
||||
func TestWorkerPollsQueuePeriodically(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
|
|
@ -408,7 +408,7 @@ func TestWorkerPollsQueuePeriodically(t *testing.T) {
|
|||
|
||||
// TestWorkerContinuesAfterEmptyQueue tests worker continues polling after empty queue
|
||||
func TestWorkerContinuesAfterEmptyQueue(t *testing.T) {
|
||||
exec := executor.NewWithDelay(10 * time.Millisecond)
|
||||
exec := executor.NewDryRunWithDelay(10 * time.Millisecond)
|
||||
p := pool.NewWithConfig(&pool.Config{
|
||||
WorkerSize: 1,
|
||||
QueueSize: 10,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ var (
|
|||
globalPool *pool.Pool
|
||||
globalDedup *dedup.Dedup
|
||||
globalStore *store.Store
|
||||
globalExecutor *executor.Executor
|
||||
globalExecutor executor.Executor
|
||||
globalPlan *plan.Plan
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,16 +7,43 @@ import (
|
|||
|
||||
// Config - robot_config in __yao.member
|
||||
type Config struct {
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *Delivery `json:"delivery,omitempty"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *Delivery `json:"delivery,omitempty"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings
|
||||
}
|
||||
|
||||
// ExecutorConfig - executor settings
|
||||
type ExecutorConfig struct {
|
||||
Mode ExecutorMode `json:"mode,omitempty"` // standard | dryrun | sandbox
|
||||
MaxDuration string `json:"max_duration,omitempty"` // max execution time (e.g., "30m")
|
||||
}
|
||||
|
||||
// GetMode returns the executor mode (default: standard)
|
||||
func (e *ExecutorConfig) GetMode() ExecutorMode {
|
||||
if e == nil || e.Mode == "" {
|
||||
return ExecutorStandard
|
||||
}
|
||||
return e.Mode
|
||||
}
|
||||
|
||||
// GetMaxDuration returns the max duration (default: 30m)
|
||||
func (e *ExecutorConfig) GetMaxDuration() time.Duration {
|
||||
if e == nil || e.MaxDuration == "" {
|
||||
return 30 * time.Minute
|
||||
}
|
||||
d, err := time.ParseDuration(e.MaxDuration)
|
||||
if err != nil {
|
||||
return 30 * time.Minute
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Validate validates the config
|
||||
|
|
|
|||
|
|
@ -250,3 +250,67 @@ func TestResourcesGetPhaseAgent(t *testing.T) {
|
|||
assert.Equal(t, "__yao.learning", resources.GetPhaseAgent(types.PhaseLearning))
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecutorConfigGetMode(t *testing.T) {
|
||||
t.Run("nil config - returns default", func(t *testing.T) {
|
||||
var config *types.ExecutorConfig
|
||||
assert.Equal(t, types.ExecutorStandard, config.GetMode())
|
||||
})
|
||||
|
||||
t.Run("empty mode - returns default", func(t *testing.T) {
|
||||
config := &types.ExecutorConfig{}
|
||||
assert.Equal(t, types.ExecutorStandard, config.GetMode())
|
||||
})
|
||||
|
||||
t.Run("standard mode", func(t *testing.T) {
|
||||
config := &types.ExecutorConfig{Mode: types.ExecutorStandard}
|
||||
assert.Equal(t, types.ExecutorStandard, config.GetMode())
|
||||
})
|
||||
|
||||
t.Run("dryrun mode", func(t *testing.T) {
|
||||
config := &types.ExecutorConfig{Mode: types.ExecutorDryRun}
|
||||
assert.Equal(t, types.ExecutorDryRun, config.GetMode())
|
||||
})
|
||||
|
||||
t.Run("sandbox mode", func(t *testing.T) {
|
||||
config := &types.ExecutorConfig{Mode: types.ExecutorSandbox}
|
||||
assert.Equal(t, types.ExecutorSandbox, config.GetMode())
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecutorConfigGetMaxDuration(t *testing.T) {
|
||||
t.Run("nil config - returns default 30m", func(t *testing.T) {
|
||||
var config *types.ExecutorConfig
|
||||
assert.Equal(t, 30*time.Minute, config.GetMaxDuration())
|
||||
})
|
||||
|
||||
t.Run("empty duration - returns default 30m", func(t *testing.T) {
|
||||
config := &types.ExecutorConfig{}
|
||||
assert.Equal(t, 30*time.Minute, config.GetMaxDuration())
|
||||
})
|
||||
|
||||
t.Run("custom duration", func(t *testing.T) {
|
||||
config := &types.ExecutorConfig{MaxDuration: "10m"}
|
||||
assert.Equal(t, 10*time.Minute, config.GetMaxDuration())
|
||||
})
|
||||
|
||||
t.Run("invalid duration - returns default", func(t *testing.T) {
|
||||
config := &types.ExecutorConfig{MaxDuration: "invalid"}
|
||||
assert.Equal(t, 30*time.Minute, config.GetMaxDuration())
|
||||
})
|
||||
|
||||
t.Run("various valid durations", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected time.Duration
|
||||
}{
|
||||
{"1h", time.Hour},
|
||||
{"30s", 30 * time.Second},
|
||||
{"2h30m", 2*time.Hour + 30*time.Minute},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
config := &types.ExecutorConfig{MaxDuration: tt.input}
|
||||
assert.Equal(t, tt.expected, config.GetMaxDuration(), "for input %s", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,3 +189,34 @@ const (
|
|||
InsertNext InsertPosition = "next" // insert after current task
|
||||
InsertAt InsertPosition = "at" // insert at specific index (use AtIndex)
|
||||
)
|
||||
|
||||
// ExecutorMode - executor mode for robot execution
|
||||
type ExecutorMode string
|
||||
|
||||
// ExecutorMode constants define the executor modes
|
||||
const (
|
||||
// ExecutorStandard uses real Agent calls (production mode)
|
||||
ExecutorStandard ExecutorMode = "standard"
|
||||
// ExecutorDryRun simulates execution without LLM calls (testing/demo)
|
||||
ExecutorDryRun ExecutorMode = "dryrun"
|
||||
// ExecutorSandbox runs in container-isolated environment (NOT IMPLEMENTED)
|
||||
// Requires Docker/gVisor/Firecracker infrastructure
|
||||
ExecutorSandbox ExecutorMode = "sandbox"
|
||||
)
|
||||
|
||||
// IsValid checks if the executor mode is valid
|
||||
func (m ExecutorMode) IsValid() bool {
|
||||
switch m {
|
||||
case ExecutorStandard, ExecutorDryRun, ExecutorSandbox, "":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetDefault returns the default executor mode if empty
|
||||
func (m ExecutorMode) GetDefault() ExecutorMode {
|
||||
if m == "" {
|
||||
return ExecutorStandard
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,3 +132,47 @@ func TestInsertPositionEnum(t *testing.T) {
|
|||
assert.Equal(t, types.InsertPosition("next"), types.InsertNext)
|
||||
assert.Equal(t, types.InsertPosition("at"), types.InsertAt)
|
||||
}
|
||||
|
||||
func TestExecutorModeEnum(t *testing.T) {
|
||||
assert.Equal(t, types.ExecutorMode("standard"), types.ExecutorStandard)
|
||||
assert.Equal(t, types.ExecutorMode("dryrun"), types.ExecutorDryRun)
|
||||
assert.Equal(t, types.ExecutorMode("sandbox"), types.ExecutorSandbox)
|
||||
}
|
||||
|
||||
func TestExecutorModeIsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode types.ExecutorMode
|
||||
valid bool
|
||||
}{
|
||||
{types.ExecutorStandard, true},
|
||||
{types.ExecutorDryRun, true},
|
||||
{types.ExecutorSandbox, true},
|
||||
{"", true}, // empty is valid (defaults to standard)
|
||||
{types.ExecutorMode("invalid"), false},
|
||||
{types.ExecutorMode("unknown"), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.mode), func(t *testing.T) {
|
||||
assert.Equal(t, tt.valid, tt.mode.IsValid())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorModeGetDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
mode types.ExecutorMode
|
||||
expected types.ExecutorMode
|
||||
}{
|
||||
{"", types.ExecutorStandard},
|
||||
{types.ExecutorStandard, types.ExecutorStandard},
|
||||
{types.ExecutorDryRun, types.ExecutorDryRun},
|
||||
{types.ExecutorSandbox, types.ExecutorSandbox},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.mode), func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, tt.mode.GetDefault())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ type Manager interface {
|
|||
// Executor - executes robot phases
|
||||
type Executor interface {
|
||||
Execute(ctx *Context, robot *Robot, trigger TriggerType, data interface{}) (*Execution, error)
|
||||
|
||||
// Metrics and control (for monitoring and testing)
|
||||
ExecCount() int // total execution count
|
||||
CurrentCount() int // currently running count
|
||||
Reset() // reset counters
|
||||
}
|
||||
|
||||
// Pool - worker pool for concurrent execution
|
||||
|
|
|
|||
|
|
@ -8,19 +8,21 @@ import (
|
|||
|
||||
// InterveneRequest - human intervention request
|
||||
type InterveneRequest struct {
|
||||
TeamID string `json:"team_id"`
|
||||
MemberID string `json:"member_id"`
|
||||
Action InterventionAction `json:"action"`
|
||||
Messages []agentcontext.Message `json:"messages"` // user input (text, images, files)
|
||||
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan
|
||||
TeamID string `json:"team_id"`
|
||||
MemberID string `json:"member_id"`
|
||||
Action InterventionAction `json:"action"`
|
||||
Messages []agentcontext.Message `json:"messages"` // user input (text, images, files)
|
||||
PlanTime *time.Time `json:"plan_time,omitempty"` // for action=plan
|
||||
ExecutorMode ExecutorMode `json:"executor_mode,omitempty"` // optional: override robot config
|
||||
}
|
||||
|
||||
// EventRequest - event trigger request
|
||||
type EventRequest struct {
|
||||
MemberID string `json:"member_id"`
|
||||
Source string `json:"source"` // webhook path or table name
|
||||
EventType string `json:"event_type"` // lead.created, etc.
|
||||
Data map[string]interface{} `json:"data"`
|
||||
MemberID string `json:"member_id"`
|
||||
Source string `json:"source"` // webhook path or table name
|
||||
EventType string `json:"event_type"` // lead.created, etc.
|
||||
Data map[string]interface{} `json:"data"`
|
||||
ExecutorMode ExecutorMode `json:"executor_mode,omitempty"` // optional: override robot config
|
||||
}
|
||||
|
||||
// ExecutionResult - trigger result
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@ type Execution struct {
|
|||
robot *Robot `json:"-"`
|
||||
}
|
||||
|
||||
// GetRobot returns the robot associated with this execution
|
||||
func (e *Execution) GetRobot() *Robot {
|
||||
return e.robot
|
||||
}
|
||||
|
||||
// SetRobot sets the robot associated with this execution
|
||||
func (e *Execution) SetRobot(robot *Robot) {
|
||||
e.robot = robot
|
||||
}
|
||||
|
||||
// TriggerInput - stored trigger input for traceability
|
||||
type TriggerInput struct {
|
||||
// For human intervention
|
||||
|
|
@ -172,7 +182,7 @@ type CurrentState struct {
|
|||
Progress string `json:"progress,omitempty"` // human-readable progress (e.g., "2/5 tasks")
|
||||
}
|
||||
|
||||
// Goals - P1 output (markdown for LLM)
|
||||
// Goals - P1 output (markdown for LLM + structured metadata)
|
||||
// P1 Agent reads InspirationReport and generates goals as markdown
|
||||
// Example:
|
||||
// ## Goals
|
||||
|
|
@ -186,6 +196,18 @@ type CurrentState struct {
|
|||
// - Reason: 3 pending leads from yesterday
|
||||
type Goals struct {
|
||||
Content string `json:"content"` // markdown text
|
||||
|
||||
// Delivery for P4 (where to send results)
|
||||
Delivery *DeliveryTarget `json:"delivery,omitempty"`
|
||||
}
|
||||
|
||||
// DeliveryTarget - where to deliver results (defined in P1, used in P4)
|
||||
type DeliveryTarget struct {
|
||||
Type DeliveryType `json:"type"` // email | webhook | report | notification
|
||||
Recipients []string `json:"recipients,omitempty"` // email addresses, webhook URLs, user IDs
|
||||
Format string `json:"format,omitempty"` // markdown | html | json | text
|
||||
Template string `json:"template,omitempty"` // template name or inline template
|
||||
Options map[string]interface{} `json:"options,omitempty"` // channel-specific options
|
||||
}
|
||||
|
||||
// Task - planned task (structured, for execution)
|
||||
|
|
@ -200,6 +222,12 @@ type Task struct {
|
|||
ExecutorID string `json:"executor_id"`
|
||||
Args []any `json:"args,omitempty"`
|
||||
|
||||
// Validation (defined in P2, used in P3)
|
||||
// ExpectedOutput describes what the task should produce (for LLM semantic validation)
|
||||
ExpectedOutput string `json:"expected_output,omitempty"` // e.g., "JSON with sales_total, growth_rate fields"
|
||||
// ValidationRules are specific checks to perform (can be semantic or structural)
|
||||
ValidationRules []string `json:"validation_rules,omitempty"` // e.g., ["output must be valid JSON", "sales_total > 0"]
|
||||
|
||||
// Runtime
|
||||
Status TaskStatus `json:"status"`
|
||||
Order int `json:"order"` // execution order (0-based)
|
||||
|
|
@ -209,20 +237,34 @@ type Task struct {
|
|||
|
||||
// TaskResult - task execution result
|
||||
type TaskResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Success bool `json:"success"`
|
||||
Output interface{} `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Duration int64 `json:"duration_ms"`
|
||||
Validated bool `json:"validated"`
|
||||
TaskID string `json:"task_id"`
|
||||
Success bool `json:"success"`
|
||||
Output interface{} `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Duration int64 `json:"duration_ms"`
|
||||
|
||||
// Validation result (populated by P3)
|
||||
Validation *ValidationResult `json:"validation,omitempty"`
|
||||
}
|
||||
|
||||
// DeliveryResult - delivery output
|
||||
// ValidationResult - P3 semantic validation result
|
||||
type ValidationResult struct {
|
||||
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)
|
||||
}
|
||||
|
||||
// DeliveryResult - P4 delivery output
|
||||
type DeliveryResult struct {
|
||||
Type DeliveryType `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Details interface{} `json:"details,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Type DeliveryType `json:"type"`
|
||||
Success bool `json:"success"`
|
||||
Recipients []string `json:"recipients,omitempty"` // who received
|
||||
Content string `json:"content,omitempty"` // formatted content that was delivered
|
||||
Details interface{} `json:"details,omitempty"` // channel-specific response
|
||||
Error string `json:"error,omitempty"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
}
|
||||
|
||||
// LearningEntry - knowledge to save
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package types_test
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
|
|
@ -397,6 +398,9 @@ func TestTaskStructure(t *testing.T) {
|
|||
ExecutorID: "assistant1",
|
||||
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"},
|
||||
}
|
||||
|
||||
assert.Equal(t, "task1", task.ID)
|
||||
|
|
@ -406,46 +410,101 @@ func TestTaskStructure(t *testing.T) {
|
|||
assert.Equal(t, "assistant1", task.ExecutorID)
|
||||
assert.Equal(t, types.TaskPending, task.Status)
|
||||
assert.Equal(t, 0, task.Order)
|
||||
// Validation fields
|
||||
assert.Contains(t, task.ExpectedOutput, "sales_total")
|
||||
assert.Len(t, task.ValidationRules, 2)
|
||||
}
|
||||
|
||||
func TestGoalsStructure(t *testing.T) {
|
||||
goals := &types.Goals{
|
||||
Content: "## Goals\n1. [High] Complete project\n2. [Normal] Review code",
|
||||
Delivery: &types.DeliveryTarget{
|
||||
Type: types.DeliveryEmail,
|
||||
Recipients: []string{"team@example.com"},
|
||||
Format: "markdown",
|
||||
},
|
||||
}
|
||||
|
||||
assert.Contains(t, goals.Content, "Goals")
|
||||
assert.Contains(t, goals.Content, "Complete project")
|
||||
assert.NotNil(t, goals.Delivery)
|
||||
assert.Equal(t, types.DeliveryEmail, goals.Delivery.Type)
|
||||
}
|
||||
|
||||
func TestTaskResultStructure(t *testing.T) {
|
||||
result := &types.TaskResult{
|
||||
TaskID: "task1",
|
||||
Success: true,
|
||||
Output: "Task completed successfully",
|
||||
Duration: 1500,
|
||||
Validated: true,
|
||||
TaskID: "task1",
|
||||
Success: true,
|
||||
Output: "Task completed successfully",
|
||||
Duration: 1500,
|
||||
Validation: &types.ValidationResult{
|
||||
Passed: true,
|
||||
Score: 0.98,
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "task1", result.TaskID)
|
||||
assert.True(t, result.Success)
|
||||
assert.Equal(t, "Task completed successfully", result.Output)
|
||||
assert.Equal(t, int64(1500), result.Duration)
|
||||
assert.True(t, result.Validated)
|
||||
assert.NotNil(t, result.Validation)
|
||||
assert.True(t, result.Validation.Passed)
|
||||
assert.Equal(t, 0.98, result.Validation.Score)
|
||||
}
|
||||
|
||||
func TestValidationResultStructure(t *testing.T) {
|
||||
validation := &types.ValidationResult{
|
||||
Passed: false,
|
||||
Score: 0.45,
|
||||
Issues: []string{"Missing required field: sales_total", "Growth rate is negative"},
|
||||
Suggestions: []string{"Add sales_total calculation", "Verify data source"},
|
||||
Details: "Detailed validation report...",
|
||||
}
|
||||
|
||||
assert.False(t, validation.Passed)
|
||||
assert.Equal(t, 0.45, validation.Score)
|
||||
assert.Len(t, validation.Issues, 2)
|
||||
assert.Contains(t, validation.Issues[0], "sales_total")
|
||||
assert.Len(t, validation.Suggestions, 2)
|
||||
}
|
||||
|
||||
func TestDeliveryResultStructure(t *testing.T) {
|
||||
sentAt := time.Now()
|
||||
delivery := &types.DeliveryResult{
|
||||
Type: types.DeliveryEmail,
|
||||
Success: true,
|
||||
Type: types.DeliveryEmail,
|
||||
Success: true,
|
||||
Recipients: []string{"user@example.com", "manager@example.com"},
|
||||
Content: "# Weekly Report\n\nSales increased by 20%...",
|
||||
Details: map[string]interface{}{
|
||||
"to": "user@example.com",
|
||||
"subject": "Daily Report",
|
||||
"message_id": "msg-12345",
|
||||
"subject": "Daily Report",
|
||||
},
|
||||
SentAt: &sentAt,
|
||||
}
|
||||
|
||||
assert.Equal(t, types.DeliveryEmail, delivery.Type)
|
||||
assert.True(t, delivery.Success)
|
||||
assert.Len(t, delivery.Recipients, 2)
|
||||
assert.Contains(t, delivery.Content, "Weekly Report")
|
||||
assert.NotNil(t, delivery.Details)
|
||||
assert.NotNil(t, delivery.SentAt)
|
||||
}
|
||||
|
||||
func TestDeliveryTargetStructure(t *testing.T) {
|
||||
delivery := &types.DeliveryTarget{
|
||||
Type: types.DeliveryEmail,
|
||||
Recipients: []string{"team@example.com"},
|
||||
Format: "markdown",
|
||||
Template: "weekly-report",
|
||||
Options: map[string]interface{}{
|
||||
"cc": []string{"manager@example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, types.DeliveryEmail, delivery.Type)
|
||||
assert.Len(t, delivery.Recipients, 1)
|
||||
assert.Equal(t, "markdown", delivery.Format)
|
||||
assert.Equal(t, "weekly-report", delivery.Template)
|
||||
}
|
||||
|
||||
func TestLearningEntryStructure(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue