Merge pull request #1421 from trheyi/main

Refactor Executor Architecture and Update Documentation
This commit is contained in:
Max 2026-01-17 10:11:07 +08:00 committed by GitHub
commit 7b6ba53abb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 5803 additions and 1007 deletions

View file

@ -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)

View file

@ -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

View file

@ -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"`

View file

@ -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

View 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)
```

View file

@ -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
}

View 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)

View file

@ -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
)

View file

@ -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",

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View 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)

View 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
}
}
}

View 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)
})
}

View 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
}

View 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)

View 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
}
}

View 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
}

View 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,
},
}
}

View 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)
})
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View file

@ -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
}

View 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
}

View 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,
}
}

View file

@ -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")
})
}

View file

@ -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(&currentConcurrent, 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(&currentConcurrent, 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(&currentConcurrent, 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,

View file

@ -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()

View file

@ -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
}

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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)

View file

@ -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(&currentConcurrent, 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,

View file

@ -17,7 +17,7 @@ var (
globalPool *pool.Pool
globalDedup *dedup.Dedup
globalStore *store.Store
globalExecutor *executor.Executor
globalExecutor executor.Executor
globalPlan *plan.Plan
)

View file

@ -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

View file

@ -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)
}
})
}

View file

@ -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
}

View file

@ -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())
})
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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) {