diff --git a/agent/llm/providers/openai/openai.go b/agent/llm/providers/openai/openai.go index 09200f6e..cc99f9e6 100644 --- a/agent/llm/providers/openai/openai.go +++ b/agent/llm/providers/openai/openai.go @@ -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) diff --git a/agent/robot/DESIGN.md b/agent/robot/DESIGN.md index efc21a30..228b71c9 100644 --- a/agent/robot/DESIGN.md +++ b/agent/robot/DESIGN.md @@ -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 diff --git a/agent/robot/TECHNICAL.md b/agent/robot/TECHNICAL.md index a96be000..256ee4a7 100644 --- a/agent/robot/TECHNICAL.md +++ b/agent/robot/TECHNICAL.md @@ -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; + + // 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"` diff --git a/agent/robot/TODO.md b/agent/robot/TODO.md index 0d238ebc..a0592c10 100644 --- a/agent/robot/TODO.md +++ b/agent/robot/TODO.md @@ -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 diff --git a/agent/robot/executor/README.md b/agent/robot/executor/README.md new file mode 100644 index 00000000..c6f53fff --- /dev/null +++ b/agent/robot/executor/README.md @@ -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) +``` diff --git a/agent/robot/executor/delivery.go b/agent/robot/executor/delivery.go deleted file mode 100644 index 72910584..00000000 --- a/agent/robot/executor/delivery.go +++ /dev/null @@ -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 -} diff --git a/agent/robot/executor/dryrun/executor.go b/agent/robot/executor/dryrun/executor.go new file mode 100644 index 00000000..0aecf4d9 --- /dev/null +++ b/agent/robot/executor/dryrun/executor.go @@ -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) diff --git a/agent/robot/executor/executor.go b/agent/robot/executor/executor.go index 1b783aa5..5ea29aa3 100644 --- a/agent/robot/executor/executor.go +++ b/agent/robot/executor/executor.go @@ -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 +) diff --git a/agent/robot/executor/executor_test.go b/agent/robot/executor/executor_test.go index f56fc0ef..cfb69eb3 100644 --- a/agent/robot/executor/executor_test.go +++ b/agent/robot/executor/executor_test.go @@ -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", diff --git a/agent/robot/executor/goals.go b/agent/robot/executor/goals.go deleted file mode 100644 index cd4d20e3..00000000 --- a/agent/robot/executor/goals.go +++ /dev/null @@ -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 -} diff --git a/agent/robot/executor/inspiration.go b/agent/robot/executor/inspiration.go deleted file mode 100644 index 515c9487..00000000 --- a/agent/robot/executor/inspiration.go +++ /dev/null @@ -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 -} diff --git a/agent/robot/executor/learning.go b/agent/robot/executor/learning.go deleted file mode 100644 index aa564dc1..00000000 --- a/agent/robot/executor/learning.go +++ /dev/null @@ -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 -} diff --git a/agent/robot/executor/run.go b/agent/robot/executor/run.go deleted file mode 100644 index 82d55f3a..00000000 --- a/agent/robot/executor/run.go +++ /dev/null @@ -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 -} diff --git a/agent/robot/executor/sandbox/executor.go b/agent/robot/executor/sandbox/executor.go new file mode 100644 index 00000000..3a066091 --- /dev/null +++ b/agent/robot/executor/sandbox/executor.go @@ -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) diff --git a/agent/robot/executor/standard/agent.go b/agent/robot/executor/standard/agent.go new file mode 100644 index 00000000..f09d42a5 --- /dev/null +++ b/agent/robot/executor/standard/agent.go @@ -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 + } + } +} diff --git a/agent/robot/executor/standard/agent_test.go b/agent/robot/executor/standard/agent_test.go new file mode 100644 index 00000000..d55a15f4 --- /dev/null +++ b/agent/robot/executor/standard/agent_test.go @@ -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) + }) +} diff --git a/agent/robot/executor/standard/delivery.go b/agent/robot/executor/standard/delivery.go new file mode 100644 index 00000000..82c040df --- /dev/null +++ b/agent/robot/executor/standard/delivery.go @@ -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 +} diff --git a/agent/robot/executor/standard/executor.go b/agent/robot/executor/standard/executor.go new file mode 100644 index 00000000..79e63fe6 --- /dev/null +++ b/agent/robot/executor/standard/executor.go @@ -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) diff --git a/agent/robot/executor/standard/goals.go b/agent/robot/executor/standard/goals.go new file mode 100644 index 00000000..d7610c2b --- /dev/null +++ b/agent/robot/executor/standard/goals.go @@ -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 + } +} diff --git a/agent/robot/executor/standard/goals_test.go b/agent/robot/executor/standard/goals_test.go new file mode 100644 index 00000000..8fcbb228 --- /dev/null +++ b/agent/robot/executor/standard/goals_test.go @@ -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 +} diff --git a/agent/robot/executor/standard/input.go b/agent/robot/executor/standard/input.go new file mode 100644 index 00000000..7b88c490 --- /dev/null +++ b/agent/robot/executor/standard/input.go @@ -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, + }, + } +} diff --git a/agent/robot/executor/standard/input_test.go b/agent/robot/executor/standard/input_test.go new file mode 100644 index 00000000..60f2c748 --- /dev/null +++ b/agent/robot/executor/standard/input_test.go @@ -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) + }) +} diff --git a/agent/robot/executor/standard/inspiration.go b/agent/robot/executor/standard/inspiration.go new file mode 100644 index 00000000..6354994f --- /dev/null +++ b/agent/robot/executor/standard/inspiration.go @@ -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 +} diff --git a/agent/robot/executor/standard/inspiration_test.go b/agent/robot/executor/standard/inspiration_test.go new file mode 100644 index 00000000..c10834b0 --- /dev/null +++ b/agent/robot/executor/standard/inspiration_test.go @@ -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 +} diff --git a/agent/robot/executor/standard/learning.go b/agent/robot/executor/standard/learning.go new file mode 100644 index 00000000..727bc6be --- /dev/null +++ b/agent/robot/executor/standard/learning.go @@ -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 +} diff --git a/agent/robot/executor/standard/run.go b/agent/robot/executor/standard/run.go new file mode 100644 index 00000000..b8849180 --- /dev/null +++ b/agent/robot/executor/standard/run.go @@ -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 +} diff --git a/agent/robot/executor/standard/tasks.go b/agent/robot/executor/standard/tasks.go new file mode 100644 index 00000000..eb464428 --- /dev/null +++ b/agent/robot/executor/standard/tasks.go @@ -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 +} diff --git a/agent/robot/executor/tasks.go b/agent/robot/executor/tasks.go deleted file mode 100644 index 1533febf..00000000 --- a/agent/robot/executor/tasks.go +++ /dev/null @@ -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 -} diff --git a/agent/robot/executor/types/helpers.go b/agent/robot/executor/types/helpers.go new file mode 100644 index 00000000..152bfc53 --- /dev/null +++ b/agent/robot/executor/types/helpers.go @@ -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 +} diff --git a/agent/robot/executor/types/types.go b/agent/robot/executor/types/types.go new file mode 100644 index 00000000..d05b4c45 --- /dev/null +++ b/agent/robot/executor/types/types.go @@ -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, + } +} diff --git a/agent/robot/manager/integration_clock_test.go b/agent/robot/manager/integration_clock_test.go index d1e8d1ab..2bf770f3 100644 --- a/agent/robot/manager/integration_clock_test.go +++ b/agent/robot/manager/integration_clock_test.go @@ -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") }) } diff --git a/agent/robot/manager/integration_concurrent_test.go b/agent/robot/manager/integration_concurrent_test.go index 8c1933f0..a213346b 100644 --- a/agent/robot/manager/integration_concurrent_test.go +++ b/agent/robot/manager/integration_concurrent_test.go @@ -50,7 +50,7 @@ func TestIntegrationConcurrentExecution(t *testing.T) { var maxConcurrent int32 var currentConcurrent int32 - exec := executor.NewWithCallback(100*time.Millisecond, + exec := executor.NewDryRunWithCallbacks(100*time.Millisecond, func() { curr := atomic.AddInt32(¤tConcurrent, 1) for { @@ -109,7 +109,7 @@ func TestIntegrationConcurrentExecution(t *testing.T) { t.Run("same robot multiple triggers", func(t *testing.T) { setupConcurrentTestRobot(t, "robot_integ_conc_same", "team_integ_conc", 3, 20) - exec := executor.NewWithDelay(50 * time.Millisecond) + exec := executor.NewDryRunWithDelay(50 * time.Millisecond) config := &manager.Config{ TickInterval: 100 * time.Millisecond, @@ -160,7 +160,7 @@ func TestIntegrationQuotaEnforcement(t *testing.T) { var maxConcurrent int32 var currentConcurrent int32 - exec := executor.NewWithCallback(200*time.Millisecond, + exec := executor.NewDryRunWithCallbacks(200*time.Millisecond, func() { curr := atomic.AddInt32(¤tConcurrent, 1) for { @@ -210,7 +210,7 @@ func TestIntegrationQuotaEnforcement(t *testing.T) { // Create robot with Max=1, Queue=3 setupConcurrentTestRobot(t, "robot_integ_quota_queue", "team_integ_quota", 1, 3) - exec := executor.NewWithDelay(300 * time.Millisecond) // Slow execution + exec := executor.NewDryRunWithDelay(300 * time.Millisecond) // Slow execution config := &manager.Config{ TickInterval: 100 * time.Millisecond, @@ -337,7 +337,7 @@ func TestIntegrationGlobalPoolLimit(t *testing.T) { var maxConcurrent int32 var currentConcurrent int32 - exec := executor.NewWithCallback(200*time.Millisecond, + exec := executor.NewDryRunWithCallbacks(200*time.Millisecond, func() { curr := atomic.AddInt32(¤tConcurrent, 1) for { @@ -391,7 +391,7 @@ func TestIntegrationGlobalPoolLimit(t *testing.T) { setupConcurrentTestRobot(t, memberID, "team_integ_pool", 5, 20) } - exec := executor.NewWithDelay(500 * time.Millisecond) // Slow execution + exec := executor.NewDryRunWithDelay(500 * time.Millisecond) // Slow execution config := &manager.Config{ TickInterval: 100 * time.Millisecond, diff --git a/agent/robot/manager/integration_test.go b/agent/robot/manager/integration_test.go index 3fb1ce30..c615460c 100644 --- a/agent/robot/manager/integration_test.go +++ b/agent/robot/manager/integration_test.go @@ -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() diff --git a/agent/robot/manager/manager.go b/agent/robot/manager/manager.go index 5c5f07bb..532fd4a4 100644 --- a/agent/robot/manager/manager.go +++ b/agent/robot/manager/manager.go @@ -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 } diff --git a/agent/robot/pool/goroutine_test.go b/agent/robot/pool/goroutine_test.go index e9cb76aa..52cc1b71 100644 --- a/agent/robot/pool/goroutine_test.go +++ b/agent/robot/pool/goroutine_test.go @@ -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, diff --git a/agent/robot/pool/pool.go b/agent/robot/pool/pool.go index 7bfbb950..4a758d66 100644 --- a/agent/robot/pool/pool.go +++ b/agent/robot/pool/pool.go @@ -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 diff --git a/agent/robot/pool/pool_test.go b/agent/robot/pool/pool_test.go index a2f90c19..b6ee0e7c 100644 --- a/agent/robot/pool/pool_test.go +++ b/agent/robot/pool/pool_test.go @@ -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, diff --git a/agent/robot/pool/queue.go b/agent/robot/pool/queue.go index 396f5f95..70651a2e 100644 --- a/agent/robot/pool/queue.go +++ b/agent/robot/pool/queue.go @@ -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 diff --git a/agent/robot/pool/worker.go b/agent/robot/pool/worker.go index b759af76..62ff62d9 100644 --- a/agent/robot/pool/worker.go +++ b/agent/robot/pool/worker.go @@ -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) diff --git a/agent/robot/pool/worker_test.go b/agent/robot/pool/worker_test.go index e31a31fd..1f6c2f1d 100644 --- a/agent/robot/pool/worker_test.go +++ b/agent/robot/pool/worker_test.go @@ -16,7 +16,7 @@ import ( // TestWorkerExecutesJob tests that worker executes a job from queue func TestWorkerExecutesJob(t *testing.T) { - exec := executor.NewWithDelay(10 * time.Millisecond) + exec := executor.NewDryRunWithDelay(10 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 1, QueueSize: 10, @@ -39,7 +39,7 @@ func TestWorkerExecutesJob(t *testing.T) { // TestWorkerMultipleJobs tests worker processes multiple jobs sequentially func TestWorkerMultipleJobs(t *testing.T) { - exec := executor.NewWithDelay(20 * time.Millisecond) + exec := executor.NewDryRunWithDelay(20 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 1, // single worker QueueSize: 10, @@ -68,7 +68,7 @@ func TestWorkerMultipleJobs(t *testing.T) { // TestWorkerRespectsRobotQuota tests worker re-enqueues when robot quota is full func TestWorkerRespectsRobotQuota(t *testing.T) { // This test verifies that all jobs eventually complete even when robot quota limits concurrency - exec := executor.NewWithDelay(100 * time.Millisecond) + exec := executor.NewDryRunWithDelay(100 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 5, // multiple workers @@ -98,7 +98,7 @@ func TestWorkerRespectsRobotQuota(t *testing.T) { // TestWorkerReenqueueOnQuotaFull tests that jobs are re-enqueued when quota is full func TestWorkerReenqueueOnQuotaFull(t *testing.T) { - exec := executor.NewWithDelay(100 * time.Millisecond) + exec := executor.NewDryRunWithDelay(100 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 3, QueueSize: 100, @@ -132,7 +132,7 @@ func TestWorkersConcurrentExecution(t *testing.T) { var maxConcurrent int32 var currentConcurrent int32 - exec := executor.NewWithCallback(100*time.Millisecond, func() { + exec := executor.NewDryRunWithCallbacks(100*time.Millisecond, func() { current := atomic.AddInt32(¤tConcurrent, 1) // Update max if current is higher for { @@ -174,7 +174,7 @@ func TestWorkersDoNotExceedPoolSize(t *testing.T) { var currentConcurrent int32 var mu sync.Mutex - exec := executor.NewWithCallback(50*time.Millisecond, func() { + exec := executor.NewDryRunWithCallbacks(50*time.Millisecond, func() { mu.Lock() currentConcurrent++ if currentConcurrent > maxConcurrent { @@ -214,7 +214,7 @@ func TestWorkersDoNotExceedPoolSize(t *testing.T) { // TestWorkerStopsGracefully tests worker stops when signaled func TestWorkerStopsGracefully(t *testing.T) { - exec := executor.NewWithDelay(50 * time.Millisecond) + exec := executor.NewDryRunWithDelay(50 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 2, QueueSize: 10, @@ -242,7 +242,7 @@ func TestWorkerStopsGracefully(t *testing.T) { // TestWorkerCompletesCurrentJobOnStop tests worker completes current job before stopping func TestWorkerCompletesCurrentJobOnStop(t *testing.T) { - exec := executor.NewWithDelay(100 * time.Millisecond) + exec := executor.NewDryRunWithDelay(100 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 1, QueueSize: 10, @@ -270,7 +270,7 @@ func TestWorkerCompletesCurrentJobOnStop(t *testing.T) { // TestWorkerHandlesExecutorError tests worker continues after executor error func TestWorkerHandlesExecutorError(t *testing.T) { - exec := executor.NewWithDelay(10 * time.Millisecond) + exec := executor.NewDryRunWithDelay(10 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 1, QueueSize: 10, @@ -299,7 +299,7 @@ func TestWorkerHandlesExecutorError(t *testing.T) { // TestWorkerRunningCounterAccurate tests running counter is accurate func TestWorkerRunningCounterAccurate(t *testing.T) { - exec := executor.NewWithDelay(100 * time.Millisecond) + exec := executor.NewDryRunWithDelay(100 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 3, QueueSize: 10, @@ -330,7 +330,7 @@ func TestWorkerRunningCounterAccurate(t *testing.T) { // TestWorkerRunningCounterDecrementsOnError tests running counter decrements on error func TestWorkerRunningCounterDecrementsOnError(t *testing.T) { - exec := executor.NewWithDelay(10 * time.Millisecond) + exec := executor.NewDryRunWithDelay(10 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 1, QueueSize: 10, @@ -356,7 +356,7 @@ func TestWorkerRunningCounterDecrementsOnError(t *testing.T) { // TestWorkerProcessesDifferentTriggers tests worker handles all trigger types func TestWorkerProcessesDifferentTriggers(t *testing.T) { - exec := executor.NewWithDelay(10 * time.Millisecond) + exec := executor.NewDryRunWithDelay(10 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 1, QueueSize: 10, @@ -384,7 +384,7 @@ func TestWorkerProcessesDifferentTriggers(t *testing.T) { // TestWorkerPollsQueuePeriodically tests worker polls queue at regular intervals func TestWorkerPollsQueuePeriodically(t *testing.T) { - exec := executor.NewWithDelay(10 * time.Millisecond) + exec := executor.NewDryRunWithDelay(10 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 1, QueueSize: 10, @@ -408,7 +408,7 @@ func TestWorkerPollsQueuePeriodically(t *testing.T) { // TestWorkerContinuesAfterEmptyQueue tests worker continues polling after empty queue func TestWorkerContinuesAfterEmptyQueue(t *testing.T) { - exec := executor.NewWithDelay(10 * time.Millisecond) + exec := executor.NewDryRunWithDelay(10 * time.Millisecond) p := pool.NewWithConfig(&pool.Config{ WorkerSize: 1, QueueSize: 10, diff --git a/agent/robot/robot.go b/agent/robot/robot.go index 760fcb2e..b46e7917 100644 --- a/agent/robot/robot.go +++ b/agent/robot/robot.go @@ -17,7 +17,7 @@ var ( globalPool *pool.Pool globalDedup *dedup.Dedup globalStore *store.Store - globalExecutor *executor.Executor + globalExecutor executor.Executor globalPlan *plan.Plan ) diff --git a/agent/robot/types/config.go b/agent/robot/types/config.go index 624e0a71..77148828 100644 --- a/agent/robot/types/config.go +++ b/agent/robot/types/config.go @@ -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 diff --git a/agent/robot/types/config_test.go b/agent/robot/types/config_test.go index e5c5dd6d..53a061b3 100644 --- a/agent/robot/types/config_test.go +++ b/agent/robot/types/config_test.go @@ -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) + } + }) +} diff --git a/agent/robot/types/enums.go b/agent/robot/types/enums.go index f5b76895..a3ba2a1b 100644 --- a/agent/robot/types/enums.go +++ b/agent/robot/types/enums.go @@ -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 +} diff --git a/agent/robot/types/enums_test.go b/agent/robot/types/enums_test.go index 10c51097..2791d4bd 100644 --- a/agent/robot/types/enums_test.go +++ b/agent/robot/types/enums_test.go @@ -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()) + }) + } +} diff --git a/agent/robot/types/interfaces.go b/agent/robot/types/interfaces.go index 3ac30c46..500f412b 100644 --- a/agent/robot/types/interfaces.go +++ b/agent/robot/types/interfaces.go @@ -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 diff --git a/agent/robot/types/request.go b/agent/robot/types/request.go index 5e6ed8e8..0269c9f2 100644 --- a/agent/robot/types/request.go +++ b/agent/robot/types/request.go @@ -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 diff --git a/agent/robot/types/robot.go b/agent/robot/types/robot.go index fc8d67ea..05da4605 100644 --- a/agent/robot/types/robot.go +++ b/agent/robot/types/robot.go @@ -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 diff --git a/agent/robot/types/robot_test.go b/agent/robot/types/robot_test.go index 34ad9050..503622c6 100644 --- a/agent/robot/types/robot_test.go +++ b/agent/robot/types/robot_test.go @@ -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) {