Merge pull request #1418 from trheyi/main

Implement Autonomous Agent (In Progress)
This commit is contained in:
Max 2026-01-14 21:08:16 +08:00 committed by GitHub
commit b7eb50857d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 9312 additions and 652 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
# Autonomous Agent
# Robot Agent
## 1. What is it?
An **Autonomous Agent** is an AI team member. It works on its own, makes decisions, and runs tasks without waiting for user input.
A **Robot Agent** is an AI team member. It works on its own, makes decisions, and runs tasks without waiting for user input.
**Key points:**
@ -95,12 +95,12 @@ Uses existing `__yao.member` model (`yao/models/member.mod.yao`):
└─────────────────────────────────────────────────────────────────┘
```
**Key fields in `__yao.member` for autonomous agents:**
**Key fields in `__yao.member` for robot agents:**
| Field | Type | Description |
| ----------------- | ------ | ----------------------------------------------------------- |
| `member_type` | enum | `user` \| `robot` |
| `autonomous_mode` | bool | Enable autonomous execution |
| `autonomous_mode` | bool | Enable robot execution |
| `robot_config` | JSON | Agent configuration (see section 5) |
| `robot_status` | enum | `idle` \| `working` \| `paused` \| `error` \| `maintenance` |
| `system_prompt` | text | Identity & role prompt |
@ -239,14 +239,16 @@ Gathers info to help make good goals. **Clock context is key input** - Agent kno
```go
type InspirationReport struct {
Clock ClockContext // Current time context
Summary string // What's happening
Highlights []Highlight // Key changes
Opportunities []Opportunity // Chances to act
Risks []Risk // Things to watch
WorldInsights []WorldInsight // News from outside
Suggestions []string // What to focus on
Clock *ClockContext `json:"clock"` // time context
Content string `json:"content"` // markdown text for LLM
}
// Content is markdown like:
// ## Summary
// ...
// ## Highlights
// - [High] Sales up 50%
// ## Opportunities / Risks / World News / Pending
// ...
type ClockContext struct {
Now time.Time // Current time
@ -292,15 +294,18 @@ Make today's goals.
### 4.4 P2: Tasks
Breaks goals into steps:
P2 Agent reads Goals markdown and breaks into executable tasks:
```go
type Task struct {
ID string
GoalID string
Description string
ExecutorType string // "assistant" | "mcp"
ExecutorID string
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
}
```
@ -346,13 +351,12 @@ type Config struct {
Clock *Clock `json:"clock,omitempty"`
Identity *Identity `json:"identity"`
Quota *Quota `json:"quota"`
PrivateKB *KB `json:"private_kb"`
SharedKB *KB `json:"shared_kb,omitempty"`
KB *KB `json:"kb,omitempty"` // shared KB (same as assistant)
DB *DB `json:"db,omitempty"` // shared DB (same as assistant)
Learn *Learn `json:"learn,omitempty"` // learning for private KB
Resources *Resources `json:"resources"`
Delivery *Delivery `json:"delivery"`
Input *Input `json:"input,omitempty"`
Events []Event `json:"events,omitempty"`
Monitor *Monitor `json:"monitor,omitempty"`
}
```
@ -454,12 +458,20 @@ type Quota struct {
}
// KB
// KB - shared knowledge base (same as assistant)
type KB struct {
ID string `json:"id,omitempty"`
Refs []string `json:"refs,omitempty"`
Learn *Learn `json:"learn,omitempty"`
Collections []string `json:"collections,omitempty"` // KB collection IDs
Options map[string]interface{} `json:"options,omitempty"`
}
// DB - shared database (same as assistant)
type DB struct {
Models []string `json:"models,omitempty"` // database model names
Options map[string]interface{} `json:"options,omitempty"`
}
// Learn - learning config for robot's private KB
// Private KB auto-created: robot_{team_id}_{member_id}_kb
type Learn struct {
On bool `json:"on"`
Types []string `json:"types"` // execution, feedback, insight
@ -485,24 +497,6 @@ type Delivery struct {
}
// Monitor
type Monitor struct {
On bool `json:"on"`
Alerts []Alert `json:"alerts,omitempty"`
}
type Alert struct {
Name string `json:"name"`
When string `json:"when"` // failed | timeout | error_rate
Value float64 `json:"value"`
Window string `json:"window"` // 1h | 24h
Do []Action `json:"do"`
Cooldown string `json:"cooldown"`
}
type Action struct {
Type string `json:"type"` // email | webhook | notify
Opts map[string]interface{} `json:"opts"`
}
```
### 5.3 Example
@ -537,14 +531,13 @@ Example record in `__yao.member` table:
"rules": ["Only access sales data"]
},
"quota": { "max": 2, "queue": 10, "priority": 5 },
"private_kb": {
"learn": {
"on": true,
"types": ["execution", "feedback", "insight"],
"keep": 90
}
"kb": { "collections": ["sales-policies", "products"] },
"db": { "models": ["sales", "customers"] },
"learn": {
"on": true,
"types": ["execution", "feedback", "insight"],
"keep": 90
},
"shared_kb": { "refs": ["sales-policies", "products"] },
"resources": {
"phases": {
"inspiration": "__yao.inspiration",
@ -663,7 +656,9 @@ stateDiagram-v2
### 7.1 Job System
Each agent = 1 Job. Each run = 1 Execution.
**Relationship:** 1 Robot : N Executions (concurrent), 1 Execution = 1 job.Job
Each trigger creates a new Execution, mapped to a `job.Job` for monitoring.
```
┌─────────────────────────────────────────────────────────────────┐
@ -807,55 +802,65 @@ type ExecutionResult struct {
}
type RobotState struct {
MemberID string // member_id from __yao.member
Status RobotStatus // idle | working | paused | error | maintenance
LastRun time.Time
NextRun time.Time
RunningID string // current execution ID if working
MemberID string // member_id from __yao.member
Status RobotStatus // idle | working | paused | error | maintenance
LastRun time.Time
NextRun time.Time
Running int // current running execution count
MaxRunning int // max concurrent executions (from Quota.Max)
RunningIDs []string // list of running execution IDs
}
```
### 8.3 Execution (Uses Job System)
No separate `autonomous_executions` table. Uses existing Job system:
No separate `autonomous_executions` table. Uses existing Job system.
**Each trigger creates a new job.Job:**
```go
// On robot member create - use Once/Cron/Daemon based on clock mode
// On each trigger (clock/human/event), create a new Job
execID := gonanoid.Must()
j, _ := job.Once(job.GOROUTINE, map[string]interface{}{
"job_id": "robot_" + memberID,
"job_id": "robot_exec_" + execID, // unique per execution
"category_id": "autonomous_robot",
"name": member.DisplayName,
"name": fmt.Sprintf("%s - %s", member.DisplayName, triggerType),
"metadata": map[string]interface{}{
"member_id": memberID,
"team_id": teamID,
"trigger_type": triggerType,
"exec_id": execID,
},
})
job.SaveJob(j)
// Add execution with config
exec := &job.Execution{
ExecutionID: gonanoid.Must(),
JobID: j.JobID,
Status: "queued",
TriggerCategory: string(TriggerClock), // or TriggerHuman, TriggerEvent
ExecutionConfig: &job.ExecutionConfig{
Type: job.ExecutionTypeProcess,
ProcessName: "autonomous.Execute",
ProcessArgs: []interface{}{memberID, triggerData},
},
// Configure and start
j.ExecutionConfig = &job.ExecutionConfig{
Type: job.ExecutionTypeProcess,
ProcessName: "robot.Execute",
ProcessArgs: []interface{}{memberID, execID, triggerData},
}
job.SaveExecution(exec)
// Start execution
j.Push()
```
// Query history
**Query executions for a robot:**
```go
// List all executions for a robot member
param := model.QueryParam{
Wheres: []model.QueryWhere{{Column: "job_id", Value: j.JobID}},
Wheres: []model.QueryWhere{
{Column: "category_id", Value: "autonomous_robot"},
{Column: "metadata->member_id", Value: memberID},
},
Orders: []model.QueryOrder{{Column: "created_at", Option: "desc"}},
}
execs, _ := job.ListExecutions(param, 1, 10)
jobs, _ := job.ListJobs(param, 1, 10)
```
**Query examples:**
```go
// List robot jobs
// List all robot jobs (all robots, all executions)
param := model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "category_id", Value: "autonomous_robot"},

1594
agent/robot/TECHNICAL.md Normal file

File diff suppressed because it is too large Load diff

653
agent/robot/TODO.md Normal file
View file

@ -0,0 +1,653 @@
# Robot Agent - Implementation TODO
> Based on DESIGN.md and TECHNICAL.md
> Test environment: `source yao/env.local.sh`
> Test assistants: `yao-dev-app/assistants/robot/`
---
## Workflow: Human-AI Collaboration
**Important:** Follow this workflow strictly for each sub-task.
```
┌─────────────────────────────────────────────────────────────────┐
│ Implementation Workflow │
├─────────────────────────────────────────────────────────────────┤
│ 1. AI: Implement code for current sub-task │
│ 2. AI: Present code for review (DO NOT write tests yet) │
│ 3. Human: Review code, provide feedback │
│ 4. AI: Iterate based on feedback │
│ 5. Human: Confirm "LGTM" or "Approved" │
│ 6. AI: Write tests for the approved code │
│ 7. Human: Review tests │
│ 8. AI: Run tests, fix if needed │
│ 9. Human: Confirm sub-task complete, move to next │
└─────────────────────────────────────────────────────────────────┘
```
**Rules:**
| Rule | Description |
| ------------------------ | ------------------------------------------ |
| One sub-task at a time | Focus only on current sub-task |
| No tests before approval | Wait for human "LGTM" before writing tests |
| No jumping ahead | Do not implement future phases |
| Ask if unclear | When in doubt, ask before proceeding |
---
## Core Principle
- Phase 1-2: Types + Skeleton (code compiles)
- Phase 3: Complete scheduling system (Cache + Pool + Trigger + Dedup + Job), executor is stub
- Phase 4-9: Implement executor phases one by one (P0 → P5)
- Phase 10: API completion, end-to-end tests
- Monitoring: Provided by Job system, no separate implementation
---
## Phase 1: Types & Interfaces ✅
**Goal:** Define all types, enums, interfaces. No logic, no external deps.
**Status:** Complete - 88.4% test coverage, all tests passing
### 1.1 Enums (`types/enums.go`)
- [x] `Phase` - execution phases (inspiration, goals, tasks, run, delivery, learning)
- [x] `ClockMode` - clock trigger modes (times, interval, daemon)
- [x] `TriggerType` - trigger sources (clock, human, event)
- [x] `ExecStatus` - execution status (pending, running, completed, failed, cancelled)
- [x] `RobotStatus` - robot status (idle, working, paused, error, maintenance)
- [x] `InterventionAction` - human actions (task.add, goal.adjust, etc.)
- [x] `Priority` - priority levels (high, normal, low)
- [x] `DeliveryType` - delivery types (email, file, webhook, notify)
- [x] `DedupResult` - dedup results (skip, merge, proceed)
- [x] `EventSource` - event sources (webhook, database)
- [x] `LearningType` - learning types (execution, feedback, insight)
- [x] `TaskSource` - task sources (auto, human, event)
- [x] `ExecutorType` - executor types (assistant, mcp, process)
- [x] `TaskStatus` - task status (pending, running, completed, failed, skipped, cancelled)
- [x] `InsertPosition` - insert positions (first, last, next, at)
### 1.2 Context (`types/context.go`)
- [x] `Context` struct - robot execution context
- [x] `NewContext()` - constructor
- [x] `UserID()`, `TeamID()` - helper methods
### 1.3 Config Types (`types/config.go`)
- [x] `Config` - main config struct
- [x] `Triggers`, `TriggerSwitch` - trigger enable/disable
- [x] `Clock` - clock config with validation
- [x] `Identity` - role, duties, rules
- [x] `Quota` - concurrency limits with defaults
- [x] `KB`, `DB` - knowledge base and database config
- [x] `Learn` - learning config
- [x] `Resources`, `MCPConfig` - available agents and tools
- [x] `Delivery` - output delivery config
- [x] `Event` - event trigger config
### 1.4 Core Types (`types/robot.go`)
- [x] `Robot` struct - runtime robot representation
- [x] `Robot` methods - `CanRun()`, `RunningCount()`, `AddExecution()`, `RemoveExecution()`, `GetExecution()`, `GetExecutions()`
- [x] `Execution` struct - single execution instance
- [x] `TriggerInput` - stored trigger input
- [x] `CurrentState` - current executing state
- [x] `Goals` - P1 output (markdown)
- [x] `Task` - planned task (structured)
- [x] `TaskResult` - task execution result
- [x] `DeliveryResult` - delivery output
- [x] `LearningEntry` - knowledge to save
### 1.5 Clock Context (`types/clock.go`)
- [x] `ClockContext` struct - time context for P0
- [x] `NewClockContext()` - constructor
### 1.6 Inspiration (`types/inspiration.go`)
- [x] `InspirationReport` struct - P0 output
### 1.7 Request/Response (`types/request.go`)
- [x] `InterveneRequest` - human intervention request
- [x] `EventRequest` - event trigger request
- [x] `ExecutionResult` - trigger result
- [x] `RobotState` - robot status query result
### 1.8 Interfaces (`types/interfaces.go`)
- [x] `Manager` interface
- [x] `Executor` interface
- [x] `Pool` interface
- [x] `Cache` interface
- [x] `Dedup` interface
- [x] `Store` interface
### 1.9 Errors (`types/errors.go`)
- [x] Config errors
- [x] Runtime errors
- [x] Phase errors
### 1.10 Tests
- [x] `types/enums_test.go` - enum validation
- [x] `types/config_test.go` - config validation
- [x] `types/clock_test.go` - clock context creation
- [x] `types/robot_test.go` - robot methods
---
## Phase 2: Skeleton Implementation ✅
**Goal:** Create all packages with empty/stub implementations. Code compiles.
**Status:** Complete - All packages compile successfully, no circular dependencies
### 2.1 Utils (`utils/`) ✅
- [x] `utils/convert.go` - JSON, map, struct conversions (implement)
- [x] `utils/time.go` - time parsing, formatting, timezone (implement)
- [x] `utils/id.go` - ID generation (nanoid) (implement)
- [x] `utils/validate.go` - validation helpers (implement)
- [x] Test: `utils/utils_test.go`
### 2.2 Package Skeletons ✅ (stubs only, implemented in Phase 3)
Create empty structs and stub methods that return nil/empty/success:
- [x] `cache/cache.go` - Cache struct, stub methods
- [x] `dedup/dedup.go` - Dedup struct, stub methods
- [x] `store/store.go` - Store struct, stub methods
- [x] `pool/pool.go` - Pool struct, stub methods
- [x] `job/job.go` - job helper stubs
- [x] `plan/plan.go` - Plan struct, stub methods
- [x] `trigger/trigger.go` - trigger dispatcher stub
- [x] `executor/executor.go` - Executor struct, stub `Execute()`
- [x] `manager/manager.go` - Manager struct, stub methods
### 2.3 API Skeletons ✅
- [x] `api/api.go` - Go API facade (all function signatures, return errors)
- [x] `api/process.go` - Yao Process registration (all processes, return errors)
- [x] `api/jsapi.go` - JSAPI registration (all methods, return errors)
### 2.4 Root ✅
- [x] `robot.go` - package entry
- [x] `Init()` - placeholder
- [x] `Shutdown()` - placeholder
### 2.5 Compile Test ✅
- [x] All packages compile without errors
- [x] All imports resolve correctly
- [x] No circular dependencies
---
## Phase 3: Complete Scheduling System
**Goal:** Implement complete scheduling system. Executor is stub (simulates success).
This phase delivers a fully working scheduling pipeline:
```
Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub) → Job
```
### ✅ 3.1 Cache Implementation (COMPLETE)
- [x] `cache/cache.go` - Cache struct with thread-safe map
- [x] `cache/load.go` - load robots from `__yao.member` where `member_type='robot'` and `autonomous_mode=true`
- [x] Implemented pagination (100 robots per page)
- [x] Configurable model name via `SetMemberModel()`
- [x] `cache/refresh.go` - refresh single robot, periodic full refresh (every hour)
- [x] Test: load/refresh with real DB
- [x] Created comprehensive integration tests with real database
- [x] Tests cover Load, LoadByID, Refresh, ListByTeam, GetByStatus
- [x] All tests passing with proper cleanup
### ✅ 3.2 Pool Implementation (COMPLETE)
- [x] `pool/pool.go` - worker pool with configurable size (global limit)
- [x] Default config: 10 workers, 100 queue size
- [x] Configurable via `pool.NewWithConfig()`
- [x] `pool/queue.go` - priority queue (sorted by: robot priority, trigger type, wait time)
- [x] Two-level limit: global queue + per-robot queue
- [x] Priority: Robot Priority × 1000 + Trigger Priority × 100
- [x] `pool/worker.go` - worker goroutines, dispatch to executor
- [x] Non-blocking quota check with re-enqueue
- [x] Graceful shutdown support
- [x] Test: submit jobs, verify execution order, verify concurrency limits
- [x] 15 test cases covering all edge cases
- [x] All tests passing
### 3.3 Trigger Implementation
- [ ] `trigger/trigger.go` - trigger dispatcher (routes to clock/intervene/event)
- [ ] `trigger/clock.go` - clock trigger
- [ ] `times` mode: match specific times (09:00, 14:00)
- [ ] `interval` mode: run every X duration (30m, 1h)
- [ ] `daemon` mode: restart immediately after completion
- [ ] Timezone handling
- [ ] `trigger/intervene.go` - human intervention
- [ ] Parse action (task.add, goal.adjust, etc.)
- [ ] Build TriggerInput with Messages
- [ ] `trigger/event.go` - event handling
- [ ] Webhook event dispatch
- [ ] Database change event dispatch
- [ ] `trigger/control.go` - execution control
- [ ] Pause execution
- [ ] Resume execution
- [ ] Cancel/Stop execution
- [ ] Test: clock matching (all modes), intervention handling, event dispatch
### 3.4 Dedup Implementation
- [ ] `dedup/dedup.go` - Dedup struct
- [ ] `dedup/fast.go` - fast in-memory time-window dedup
- [ ] Key: `memberID:triggerType:window`
- [ ] Check before submit
- [ ] Mark after submit
- [ ] Test: dedup check/mark, window expiry
### 3.5 Job Integration
- [ ] `job/job.go` - create job
- [ ] `job_id`: `robot_exec_{execID}`
- [ ] `category_id`: `autonomous_robot`
- [ ] Metadata: member_id, team_id, trigger_type, exec_id
- [ ] `job/execution.go` - execution lifecycle
- [ ] Create execution on trigger
- [ ] Update status on phase change
- [ ] Complete/fail on finish
- [ ] `job/log.go` - write phase logs
- [ ] Log phase start/end
- [ ] Log errors
- [ ] Test: job creation, execution tracking, log writing
### 3.6 Manager Implementation
- [ ] `manager/manager.go` - Manager struct
- [ ] `Start()` - start ticker goroutine, start pool
- [ ] `Stop()` - graceful shutdown (wait for running, drain queue)
- [ ] `Tick()` - main loop:
1. Get all cached robots
2. For each robot with clock trigger enabled
3. Check if should execute (schedule match + dedup)
4. Submit to pool
- [ ] Test: manager start/stop, tick cycle
### 3.7 Executor Stub
- [ ] `executor/executor.go` - stub implementation
- [ ] `Execute()` - simulate full execution
1. Create Execution record
2. Update phase: P0 → P1 → P2 → P3 → P4 → P5
3. Sleep briefly between phases (simulate work)
4. Return success with mock data
- [ ] Test: verify stub called, verify phase progression
### 3.8 Integration Test (End-to-End Scheduling)
- [ ] Create test robot in `__yao.member` with clock config
- [ ] Start manager
- [ ] Wait for clock trigger
- [ ] Verify:
- [ ] Robot loaded to cache
- [ ] Clock trigger matched
- [ ] Dedup checked
- [ ] Job submitted to pool
- [ ] Worker picked up job
- [ ] Executor stub called
- [ ] Job execution recorded
- [ ] Logs written
- [ ] Test human intervention trigger
- [ ] Test event trigger
- [ ] Test concurrent executions (multiple robots)
- [ ] Test quota enforcement (per-robot limit)
- [ ] Test pause/resume/stop
---
## Phase 4: Executor - P0 Inspiration
**Goal:** Implement P0 (Inspiration Agent). Clock trigger → P0 → stub P1-P5.
### 4.1 Test Assistant Setup
Create `yao-dev-app/assistants/robot/` directory:
- [ ] `inspiration/package.yao` - Inspiration Agent config
- [ ] `inspiration/prompts.yml` - P0 prompts
- [ ] `inspiration/src/index.ts` - hooks if needed
### 4.2 P0 Implementation
- [ ] `executor/inspiration.go` - build prompt with `ClockContext`
- [ ] `executor/inspiration.go` - call Inspiration Agent
- [ ] `executor/inspiration.go` - parse response to `InspirationReport`
- [ ] `executor/prompt.go` - `BuildInspirationPrompt()`
### 4.3 Tests
- [ ] `executor/inspiration_test.go` - P0 with real LLM call
- [ ] Verify: clock context in prompt
- [ ] Verify: markdown report generated
---
## Phase 5: Executor - P1 Goals
**Goal:** Implement P1 (Goal Generation Agent). P0 → P1 → stub P2-P5.
### 5.1 Test Assistant Setup
- [ ] `goals/package.yao` - Goal Generation Agent config
- [ ] `goals/prompts.yml` - P1 prompts
### 5.2 P1 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()`
### 5.3 Tests
- [ ] `executor/goals_test.go` - P1 with real LLM call
- [ ] Verify: inspiration report in prompt
- [ ] Verify: goals markdown generated
---
## Phase 6: Executor - P2 Tasks
**Goal:** Implement P2 (Task Planning Agent). P1 → P2 → stub P3-P5.
### 6.1 Test Assistant Setup
- [ ] `tasks/package.yao` - Task Planning Agent config
- [ ] `tasks/prompts.yml` - P2 prompts
### 6.2 P2 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()`
### 6.3 Tests
- [ ] `executor/tasks_test.go` - P2 with real LLM call
- [ ] Verify: goals in prompt
- [ ] Verify: structured tasks generated
---
## Phase 7: Executor - P3 Run
**Goal:** Implement P3 (Task Execution). P2 → P3 → stub P4-P5.
### 7.1 Implementation
- [ ] `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
### 7.2 Validation Agent Setup
- [ ] `validation/package.yao` - Validation Agent config
- [ ] `validation/prompts.yml` - validation prompts
### 7.3 Tests
- [ ] `executor/run_test.go` - P3 with real agent calls
- [ ] Verify: tasks executed in order
- [ ] Verify: results collected
---
## Phase 8: Executor - P4 Delivery
**Goal:** Implement P4 (Delivery). P3 → P4 → stub P5.
### 8.1 Test Assistant Setup
- [ ] `delivery/package.yao` - Delivery Agent config
- [ ] `delivery/prompts.yml` - delivery prompts
### 8.2 Implementation
- [ ] `executor/delivery.go` - build delivery content
- [ ] `executor/delivery.go` - send via configured channel (email/file/webhook/notify)
### 8.3 Tests
- [ ] `executor/delivery_test.go` - P4 delivery
- [ ] Verify: delivery sent (mock or real)
---
## Phase 9: Executor - P5 Learning
**Goal:** Implement P5 (Learning). Full execution flow complete.
### 9.1 Test Assistant Setup
- [ ] `learning/package.yao` - Learning Agent config
- [ ] `learning/prompts.yml` - learning prompts
### 9.2 Store Implementation
- [ ] `store/kb.go` - KB operations (create, save, search)
- [ ] `store/learning.go` - save learning entries to private KB
### 9.3 Implementation
- [ ] `executor/learning.go` - extract learnings from execution
- [ ] `executor/learning.go` - call Learning Agent
- [ ] `executor/learning.go` - save to private KB
### 9.4 Tests
- [ ] `executor/learning_test.go` - P5 learning
- [ ] Verify: learnings saved to KB
---
## Phase 10: API & Integration
**Goal:** Complete API implementation, end-to-end tests.
### 10.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
- [ ] Full clock trigger flow (P0 → P5)
- [ ] Human intervention flow (P1 → P5)
- [ ] Event trigger flow (P1 → P5)
- [ ] Concurrent execution test
- [ ] Pause/Resume/Stop test
### 10.3 Integration with OpenAPI
- [ ] HTTP endpoints for human intervention
- [ ] Webhook endpoints for events
---
## Phase 11: Advanced Features
**Goal:** Implement semantic dedup, plan queue.
### 11.1 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.2 Plan Queue
- [ ] `plan/plan.go` - plan queue implementation
- [ ] Store planned tasks/goals
- [ ] Execute at next cycle or specified time
- [ ] `plan/schedule.go` - schedule for later
- [ ] Test: plan queue operations
> **Note:** Monitoring is provided by Job system (Activity Monitor UI). No separate implementation needed.
---
## Test Assistants Structure
```
yao-dev-app/assistants/robot/
├── inspiration/ # P0: Inspiration Agent
│ ├── package.yao
│ └── prompts.yml
├── goals/ # P1: Goal Generation Agent
│ ├── package.yao
│ └── prompts.yml
├── tasks/ # P2: Task Planning Agent
│ ├── package.yao
│ └── prompts.yml
├── validation/ # P3: Validation Agent
│ ├── package.yao
│ └── prompts.yml
├── delivery/ # P4: Delivery Agent
│ ├── package.yao
│ └── prompts.yml
├── learning/ # P5: Learning Agent
│ ├── package.yao
│ └── prompts.yml
└── dedup/ # Deduplication Agent
├── package.yao
└── prompts.yml
```
---
## Notes
### Test Environment Setup
1. **Environment Variables:** Run `source yao/env.local.sh` before tests
2. **Test Preparation:** Use `testutils.Prepare(t)` to load config, KB, and agents
```go
package robot_test
import (
"testing"
"github.com/yaoapp/yao/agent/testutils"
)
func TestExample(t *testing.T) {
// Load environment config (from YAO_TEST_APPLICATION)
// This loads: config, connectors, KB, agents, models, etc.
testutils.Prepare(t)
defer testutils.Clean(t)
// Your test code here
}
```
### Test Conventions
1. **Black-box Tests:** All tests in `*_test` package (external package)
2. **Real LLM Calls:** Use `gpt-4o` or `deepseek` connectors for agent tests
3. **Incremental:** Each phase builds on previous, all tests must pass before next phase
4. **No Skip:** Do NOT use `t.Skip()` except for `testing.Short()` (CI mode)
5. **Must Assert:** Every test MUST have result validation assertions
```go
func TestWithLLM(t *testing.T) {
// Only allowed Skip: testing.Short() for CI
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Your test code...
result, err := SomeFunction()
// MUST have assertions - no empty tests!
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, expected, result.Field)
}
```
### Test Rules
| Rule | Description |
| ----------------- | ----------------------------------------- |
| No arbitrary Skip | Only `testing.Short()` skip allowed |
| Must assert | Every test must validate results |
| No empty tests | Tests without assertions will fail review |
| Real calls | LLM tests use real API calls, not mocks |
### Key Environment Variables
| Variable | Description |
| ---------------------- | ------------------------------- |
| `YAO_TEST_APPLICATION` | Test app path (`yao-dev-app`) |
| `OPENAI_TEST_KEY` | OpenAI API key |
| `DEEPSEEK_API_KEY` | DeepSeek API key |
| `YAO_DB_DRIVER` | Database driver (mysql/sqlite3) |
| `YAO_DB_PRIMARY` | Database connection string |
---
## Progress Tracking
| Phase | Status | Description |
| --------------------- | ------ | ---------------------------------------------------- |
| 1. Types & Interfaces | ✅ | All types, enums, interfaces |
| 2. Skeleton | ✅ | Empty stubs, code compiles |
| 3. Scheduling System | ⬜ | Cache + Pool + Trigger + Dedup + Job (executor stub) |
| 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 |
Legend: ⬜ Not started | 🟡 In progress | ✅ Complete
---
## Quick Commands
```bash
# Setup environment
source yao/env.local.sh
# Run all robot tests
go test -v ./agent/robot/...
# Run specific phase tests
go test -v ./agent/robot/types/...
go test -v ./agent/robot/cache/...
go test -v ./agent/robot/pool/...
go test -v ./agent/robot/executor/...
# Run with coverage
go test -cover ./agent/robot/...
```

200
agent/robot/api/api.go Normal file
View file

@ -0,0 +1,200 @@
package api
import (
"time"
"github.com/yaoapp/yao/agent/robot/types"
)
// ==================== CRUD ====================
// Get returns a robot by member ID
// Stub: returns error (will be implemented in Phase 10)
func Get(ctx *types.Context, memberID string) (*types.Robot, error) {
return nil, types.ErrRobotNotFound
}
// List returns robots with pagination and filtering
// Stub: returns empty result (will be implemented in Phase 10)
func List(ctx *types.Context, query *ListQuery) (*ListResult, error) {
return &ListResult{
Data: []*types.Robot{},
Total: 0,
Page: query.Page,
PageSize: query.PageSize,
}, nil
}
// Create creates a new robot member
// Stub: returns error (will be implemented in Phase 10)
func Create(ctx *types.Context, teamID string, req *CreateRequest) (*types.Robot, error) {
return nil, types.ErrRobotNotFound
}
// Update updates robot config
// Stub: returns error (will be implemented in Phase 10)
func Update(ctx *types.Context, memberID string, req *UpdateRequest) (*types.Robot, error) {
return nil, types.ErrRobotNotFound
}
// Remove deletes a robot member
// Stub: returns error (will be implemented in Phase 10)
func Remove(ctx *types.Context, memberID string) error {
return types.ErrRobotNotFound
}
// ==================== Status ====================
// Status returns current robot runtime state
// Stub: returns empty state (will be implemented in Phase 10)
func Status(ctx *types.Context, memberID string) (*RobotState, error) {
return &RobotState{
MemberID: memberID,
Status: types.RobotIdle,
Running: 0,
}, nil
}
// UpdateStatus updates robot status (idle, paused, etc.)
// Stub: returns nil (will be implemented in Phase 10)
func UpdateStatus(ctx *types.Context, memberID string, status types.RobotStatus) error {
return nil
}
// ==================== Trigger ====================
// Trigger starts execution with specified trigger type and request
// Stub: returns empty result (will be implemented in Phase 10)
func Trigger(ctx *types.Context, memberID string, req *TriggerRequest) (*TriggerResult, error) {
return &TriggerResult{
Accepted: false,
Message: "not implemented",
}, nil
}
// ==================== Execution ====================
// GetExecutions returns execution history
// Stub: returns empty result (will be implemented in Phase 10)
func GetExecutions(ctx *types.Context, memberID string, query *ExecutionQuery) (*ExecutionResult, error) {
return &ExecutionResult{
Data: []*types.Execution{},
Total: 0,
Page: query.Page,
PageSize: query.PageSize,
}, nil
}
// GetExecution returns a specific execution by ID
// Stub: returns nil (will be implemented in Phase 10)
func GetExecution(ctx *types.Context, execID string) (*types.Execution, error) {
return nil, types.ErrRobotNotFound
}
// Pause pauses a running execution
// Stub: returns nil (will be implemented in Phase 10)
func Pause(ctx *types.Context, execID string) error {
return nil
}
// Resume resumes a paused execution
// Stub: returns nil (will be implemented in Phase 10)
func Resume(ctx *types.Context, execID string) error {
return nil
}
// Stop stops a running execution
// Stub: returns nil (will be implemented in Phase 10)
func Stop(ctx *types.Context, execID string) error {
return nil
}
// ==================== API Types ====================
// CreateRequest - request for Create()
type CreateRequest struct {
DisplayName string `json:"display_name"`
SystemPrompt string `json:"system_prompt,omitempty"`
Config *types.Config `json:"robot_config"`
}
// UpdateRequest - request for Update()
type UpdateRequest struct {
DisplayName *string `json:"display_name,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
Config *types.Config `json:"robot_config,omitempty"`
}
// ListQuery - query options for List()
type ListQuery struct {
TeamID string `json:"team_id,omitempty"`
Status types.RobotStatus `json:"status,omitempty"`
Keywords string `json:"keywords,omitempty"`
ClockMode types.ClockMode `json:"clock_mode,omitempty"`
Page int `json:"page,omitempty"`
PageSize int `json:"pagesize,omitempty"`
Order string `json:"order,omitempty"`
}
// ListResult - result of List()
type ListResult struct {
Data []*types.Robot `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pagesize"`
}
// RobotState - runtime state from Status()
type RobotState struct {
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
DisplayName string `json:"display_name"`
Status types.RobotStatus `json:"status"`
Running int `json:"running"`
MaxRunning int `json:"max_running"`
LastRun *time.Time `json:"last_run,omitempty"`
NextRun *time.Time `json:"next_run,omitempty"`
RunningIDs []string `json:"running_ids,omitempty"`
}
// TriggerRequest - request for Trigger()
type TriggerRequest struct {
Type types.TriggerType `json:"type"` // human | event
// Human intervention fields (when Type = human)
Action types.InterventionAction `json:"action,omitempty"`
Messages []interface{} `json:"messages,omitempty"` // context.Message
PlanAt *time.Time `json:"plan_at,omitempty"`
InsertPosition types.InsertPosition `json:"insert_at,omitempty"`
AtIndex int `json:"at_index,omitempty"`
// Event fields (when Type = event)
Source types.EventSource `json:"source,omitempty"`
EventType string `json:"event_type,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
// TriggerResult - result of Trigger()
type TriggerResult struct {
Accepted bool `json:"accepted"`
Queued bool `json:"queued"`
Execution *types.Execution `json:"execution,omitempty"`
JobID string `json:"job_id,omitempty"`
Message string `json:"message,omitempty"`
}
// ExecutionQuery - query options for GetExecutions()
type ExecutionQuery struct {
Status types.ExecStatus `json:"status,omitempty"`
Trigger types.TriggerType `json:"trigger,omitempty"`
Page int `json:"page,omitempty"`
PageSize int `json:"pagesize,omitempty"`
}
// ExecutionResult - result of GetExecutions()
type ExecutionResult struct {
Data []*types.Execution `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pagesize"`
}

18
agent/robot/api/jsapi.go Normal file
View file

@ -0,0 +1,18 @@
package api
// JSAPI for V8 Runtime
// Stub: all JSAPI methods return errors (will be implemented in Phase 10)
// This file defines the JavaScript API structure
// Implementation will be added in Phase 10
// ExportFunction exports the Robot constructor to V8
// Stub: not implemented yet (will be implemented in Phase 10)
// ExportObject exports the robot global object to V8
// Stub: not implemented yet (will be implemented in Phase 10)
// The actual V8 integration will be implemented in Phase 10 following the pattern:
// - Robot constructor: new Robot(memberID)
// - Global robot object: robot.List(), robot.Get(), etc.
// - Instance methods: bot.Status(), bot.Trigger(), etc.

View file

@ -0,0 +1,82 @@
package api
// Process API for Yao Process system
// Stub: all process handlers return errors (will be implemented in Phase 10)
// processGet handles robot.Get process
// Stub: returns error (will be implemented in Phase 10)
func processGet(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processList handles robot.List process
// Stub: returns error (will be implemented in Phase 10)
func processList(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processCreate handles robot.Create process
// Stub: returns error (will be implemented in Phase 10)
func processCreate(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processUpdate handles robot.Update process
// Stub: returns error (will be implemented in Phase 10)
func processUpdate(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processRemove handles robot.Remove process
// Stub: returns error (will be implemented in Phase 10)
func processRemove(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processStatus handles robot.Status process
// Stub: returns error (will be implemented in Phase 10)
func processStatus(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processUpdateStatus handles robot.UpdateStatus process
// Stub: returns error (will be implemented in Phase 10)
func processUpdateStatus(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processTrigger handles robot.Trigger process
// Stub: returns error (will be implemented in Phase 10)
func processTrigger(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processExecutions handles robot.Executions process
// Stub: returns error (will be implemented in Phase 10)
func processExecutions(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processExecution handles robot.Execution process
// Stub: returns error (will be implemented in Phase 10)
func processExecution(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processPause handles robot.Pause process
// Stub: returns error (will be implemented in Phase 10)
func processPause(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processResume handles robot.Resume process
// Stub: returns error (will be implemented in Phase 10)
func processResume(args ...interface{}) (interface{}, error) {
return nil, nil
}
// processStop handles robot.Stop process
// Stub: returns error (will be implemented in Phase 10)
func processStop(args ...interface{}) (interface{}, error) {
return nil, nil
}

99
agent/robot/cache/cache.go vendored Normal file
View file

@ -0,0 +1,99 @@
package cache
import (
"sync"
"github.com/yaoapp/yao/agent/robot/types"
)
// Cache implements types.Cache interface
// Thread-safe in-memory cache for Robot instances
type Cache struct {
robots map[string]*types.Robot // memberID -> Robot
byTeam map[string][]string // teamID -> memberIDs
mu sync.RWMutex
}
// New creates a new cache instance
func New() *Cache {
return &Cache{
robots: make(map[string]*types.Robot),
byTeam: make(map[string][]string),
}
}
// Get returns a robot by member ID
// Stub: returns nil (will be implemented in Phase 3)
func (c *Cache) Get(memberID string) *types.Robot {
c.mu.RLock()
defer c.mu.RUnlock()
return c.robots[memberID]
}
// List returns all robots for a team
func (c *Cache) List(teamID string) []*types.Robot {
c.mu.RLock()
defer c.mu.RUnlock()
memberIDs := c.byTeam[teamID]
robots := make([]*types.Robot, 0, len(memberIDs))
for _, memberID := range memberIDs {
if robot := c.robots[memberID]; robot != nil {
robots = append(robots, robot)
}
}
return robots
}
// Note: Refresh is implemented in refresh.go
// Add adds or updates a robot in cache
func (c *Cache) Add(robot *types.Robot) {
if robot == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.robots[robot.MemberID] = robot
// Update team index
if _, exists := c.byTeam[robot.TeamID]; !exists {
c.byTeam[robot.TeamID] = []string{}
}
// Check if member ID already in team list
found := false
for _, id := range c.byTeam[robot.TeamID] {
if id == robot.MemberID {
found = true
break
}
}
if !found {
c.byTeam[robot.TeamID] = append(c.byTeam[robot.TeamID], robot.MemberID)
}
}
// Remove removes a robot from cache
func (c *Cache) Remove(memberID string) {
c.mu.Lock()
defer c.mu.Unlock()
robot := c.robots[memberID]
if robot == nil {
return
}
delete(c.robots, memberID)
// Remove from team index
teamMembers := c.byTeam[robot.TeamID]
for i, id := range teamMembers {
if id == memberID {
c.byTeam[robot.TeamID] = append(teamMembers[:i], teamMembers[i+1:]...)
break
}
}
}

481
agent/robot/cache/cache_test.go vendored Normal file
View file

@ -0,0 +1,481 @@
package cache_test
import (
"context"
"encoding/json"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/xun/capsule"
"github.com/yaoapp/yao/agent/robot/cache"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// TestCacheLoad tests loading all active robots from database
func TestCacheLoad(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Clean up any existing test data first
cleanupTestRobots(t)
// Create test robots in database
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
// Load all robots
err := c.Load(ctx)
assert.NoError(t, err)
// Count should be at least 2 (may have other robots in DB)
count := c.Count()
assert.GreaterOrEqual(t, count, 2, "Should load at least 2 active autonomous robots")
// Verify first robot
robot1 := c.Get("robot_test_sales_001")
assert.NotNil(t, robot1, "Sales bot should be loaded")
if robot1 == nil {
t.Fatal("robot_test_sales_001 not found in cache")
}
assert.Equal(t, "robot_test_sales_001", robot1.MemberID)
assert.Equal(t, "team_test_cache_001", robot1.TeamID)
assert.Equal(t, "Test Sales Bot", robot1.DisplayName)
assert.Equal(t, types.RobotIdle, robot1.Status)
assert.True(t, robot1.AutonomousMode)
assert.NotNil(t, robot1.Config, "Robot config should be parsed")
assert.NotNil(t, robot1.Config.Identity, "Identity should be parsed")
assert.Equal(t, "Sales Manager", robot1.Config.Identity.Role)
assert.Equal(t, 3, robot1.Config.Quota.GetMax())
// Verify second robot
robot2 := c.Get("robot_test_support_002")
assert.NotNil(t, robot2, "Support bot should be loaded")
assert.Equal(t, "robot_test_support_002", robot2.MemberID)
assert.Equal(t, "Test Support Bot", robot2.DisplayName)
assert.NotNil(t, robot2.Config)
assert.Equal(t, "Customer Support", robot2.Config.Identity.Role)
assert.Equal(t, 2, robot2.Config.Quota.GetMax())
// Verify inactive robot is not loaded
robot3 := c.Get("robot_test_inactive_003")
assert.Nil(t, robot3, "Inactive robot should not be loaded")
}
// TestCacheLoadByID tests loading a single robot by member ID
func TestCacheLoadByID(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
t.Run("load existing robot", func(t *testing.T) {
robot, err := c.LoadByID(ctx, "robot_test_sales_001")
assert.NoError(t, err)
assert.NotNil(t, robot)
assert.Equal(t, "robot_test_sales_001", robot.MemberID)
assert.Equal(t, "Test Sales Bot", robot.DisplayName)
assert.NotNil(t, robot.Config)
})
t.Run("load non-existent robot", func(t *testing.T) {
robot, err := c.LoadByID(ctx, "robot_nonexistent")
assert.Error(t, err)
assert.Equal(t, types.ErrRobotNotFound, err)
assert.Nil(t, robot)
})
t.Run("load inactive robot by ID", func(t *testing.T) {
// LoadByID doesn't filter by status, so it should load
robot, err := c.LoadByID(ctx, "robot_test_inactive_003")
assert.NoError(t, err)
assert.NotNil(t, robot)
assert.Equal(t, "robot_test_inactive_003", robot.MemberID)
})
}
// TestCacheRefresh tests refreshing a single robot from database
func TestCacheRefresh(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
// Load initial data
err := c.Load(ctx)
assert.NoError(t, err)
t.Run("refresh existing robot", func(t *testing.T) {
err := c.Refresh(ctx, "robot_test_sales_001")
assert.NoError(t, err)
// Robot should still be in cache
robot := c.Get("robot_test_sales_001")
assert.NotNil(t, robot)
})
t.Run("refresh removes non-existent robot", func(t *testing.T) {
// Add a fake robot to cache
c.Add(&types.Robot{MemberID: "robot_test_fake", TeamID: "team_test_cache_001"})
assert.NotNil(t, c.Get("robot_test_fake"))
// Refresh should remove it
err := c.Refresh(ctx, "robot_test_fake")
assert.NoError(t, err)
assert.Nil(t, c.Get("robot_test_fake"), "Non-existent robot should be removed")
})
}
// TestCacheListByTeam tests listing robots by team
func TestCacheListByTeam(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
// Load all robots
err := c.Load(ctx)
assert.NoError(t, err)
// List robots by team
robots := c.List("team_test_cache_001")
assert.Len(t, robots, 2, "Should have 2 robots in team_test_cache_001")
// List robots for non-existent team
robots = c.List("team_nonexistent")
assert.Len(t, robots, 0, "Non-existent team should have no robots")
}
// TestCacheGetByStatus tests getting robots by status
func TestCacheGetByStatus(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
// Load all robots
err := c.Load(ctx)
assert.NoError(t, err)
// Get idle robots (may have others in DB)
idle := c.GetIdle()
assert.GreaterOrEqual(t, len(idle), 2, "Should have at least 2 idle robots")
// Verify our test robots are not working
testRobot1 := c.Get("robot_test_sales_001")
testRobot2 := c.Get("robot_test_support_002")
assert.Equal(t, types.RobotIdle, testRobot1.Status, "Test robot 1 should be idle")
assert.Equal(t, types.RobotIdle, testRobot2.Status, "Test robot 2 should be idle")
}
// TestCacheAutoRefresh tests auto-refresh functionality and goroutine leak prevention
func TestCacheAutoRefresh(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestRobots(t)
setupTestRobots(t)
defer cleanupTestRobots(t)
c := cache.New()
ctx := types.NewContext(context.Background(), nil)
// Load initial data
err := c.Load(ctx)
assert.NoError(t, err)
initialCount := c.Count()
t.Run("start and stop auto-refresh", func(t *testing.T) {
// Record initial goroutine count
runtime.GC()
time.Sleep(100 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
// Start auto-refresh with short interval
config := &cache.RefreshConfig{Interval: 100 * time.Millisecond}
c.StartAutoRefresh(ctx, config)
// Wait a bit to let it run (should trigger at least 2 refreshes)
time.Sleep(250 * time.Millisecond)
// Stop auto-refresh
c.StopAutoRefresh()
// Wait for goroutine to exit
time.Sleep(100 * time.Millisecond)
runtime.GC()
time.Sleep(50 * time.Millisecond)
// Check for goroutine leak
finalGoroutines := runtime.NumGoroutine()
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
"Should not leak goroutines after stop (initial: %d, final: %d)",
initialGoroutines, finalGoroutines)
// Should still have robots
assert.GreaterOrEqual(t, c.Count(), initialCount)
})
t.Run("multiple start calls should not leak goroutines", func(t *testing.T) {
// Record initial goroutine count
runtime.GC()
time.Sleep(100 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
// Start multiple times without stopping
// This should not create multiple goroutines or ticker leaks
config := &cache.RefreshConfig{Interval: 100 * time.Millisecond}
c.StartAutoRefresh(ctx, config)
time.Sleep(50 * time.Millisecond)
c.StartAutoRefresh(ctx, config) // Should stop previous one
time.Sleep(50 * time.Millisecond)
c.StartAutoRefresh(ctx, config) // Should stop previous one
time.Sleep(50 * time.Millisecond)
// After multiple starts, should only have 1 goroutine running
afterStartsGoroutines := runtime.NumGoroutine()
assert.LessOrEqual(t, afterStartsGoroutines, initialGoroutines+2,
"Multiple starts should not accumulate goroutines (initial: %d, after starts: %d)",
initialGoroutines, afterStartsGoroutines)
// Stop once should be enough
c.StopAutoRefresh()
// Wait for cleanup
time.Sleep(100 * time.Millisecond)
runtime.GC()
time.Sleep(50 * time.Millisecond)
// Should be back to initial count
finalGoroutines := runtime.NumGoroutine()
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
"Should cleanup all goroutines after final stop (initial: %d, final: %d)",
initialGoroutines, finalGoroutines)
// Should still work correctly
assert.GreaterOrEqual(t, c.Count(), initialCount)
})
t.Run("stop without start should not panic", func(t *testing.T) {
// Multiple stops should be safe
assert.NotPanics(t, func() {
c.StopAutoRefresh()
c.StopAutoRefresh()
c.StopAutoRefresh()
})
})
t.Run("concurrent start and stop should be safe", func(t *testing.T) {
// Record initial goroutine count
runtime.GC()
time.Sleep(100 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
config := &cache.RefreshConfig{Interval: 50 * time.Millisecond}
// Rapidly start and stop multiple times
for i := 0; i < 10; i++ {
c.StartAutoRefresh(ctx, config)
time.Sleep(10 * time.Millisecond)
c.StopAutoRefresh()
time.Sleep(10 * time.Millisecond)
}
// Final cleanup
c.StopAutoRefresh()
time.Sleep(100 * time.Millisecond)
runtime.GC()
time.Sleep(50 * time.Millisecond)
// Should not have leaked goroutines
finalGoroutines := runtime.NumGoroutine()
assert.LessOrEqual(t, finalGoroutines, initialGoroutines+1,
"Rapid start/stop cycles should not leak goroutines (initial: %d, final: %d)",
initialGoroutines, finalGoroutines)
})
}
// setupTestRobots creates 3 test robot records in database
func setupTestRobots(t *testing.T) {
// Get the actual table name from model
m := model.Select("__yao.member")
tableName := m.MetaData.Table.Name
qb := capsule.Query()
// Robot 1: Sales Bot (active, autonomous)
robotConfig1 := map[string]interface{}{
"identity": map[string]interface{}{
"role": "Sales Manager",
"duties": []string{"Manage leads", "Follow up customers"},
"rules": []string{"Be professional", "Reply within 24h"},
},
"quota": map[string]interface{}{
"max": 3,
"queue": 15,
"priority": 7,
},
"clock": map[string]interface{}{
"mode": "times",
"times": []string{"09:00", "14:00"},
"tz": "Asia/Shanghai",
},
}
config1JSON, _ := json.Marshal(robotConfig1)
err := qb.Table(tableName).Insert([]map[string]interface{}{
{
"member_id": "robot_test_sales_001",
"team_id": "team_test_cache_001",
"member_type": "robot",
"display_name": "Test Sales Bot",
"system_prompt": "You are a professional sales manager assistant.",
"status": "active",
"role_id": "member", // required field
"autonomous_mode": true,
"robot_status": "idle",
"robot_config": string(config1JSON),
},
})
if err != nil {
t.Fatalf("Failed to insert robot_test_sales_001: %v", err)
}
// Robot 2: Support Bot (active, autonomous)
robotConfig2 := map[string]interface{}{
"identity": map[string]interface{}{
"role": "Customer Support",
"duties": []string{"Answer questions", "Resolve issues"},
},
"quota": map[string]interface{}{
"max": 2,
"queue": 10,
"priority": 5,
},
"clock": map[string]interface{}{
"mode": "interval",
"every": "1h",
},
}
config2JSON, _ := json.Marshal(robotConfig2)
err = qb.Table(tableName).Insert([]map[string]interface{}{
{
"member_id": "robot_test_support_002",
"team_id": "team_test_cache_001",
"member_type": "robot",
"display_name": "Test Support Bot",
"system_prompt": "You are a helpful customer support assistant.",
"status": "active",
"role_id": "member", // required field
"autonomous_mode": true,
"robot_status": "idle",
"robot_config": string(config2JSON),
},
})
if err != nil {
t.Fatalf("Failed to insert robot_test_support_002: %v", err)
}
// Robot 3: Inactive robot (should not be loaded by Load())
err = qb.Table(tableName).Insert([]map[string]interface{}{
{
"member_id": "robot_test_inactive_003",
"team_id": "team_test_cache_001",
"member_type": "robot",
"display_name": "Test Inactive Bot",
"status": "inactive",
"role_id": "member", // required field
"autonomous_mode": true,
"robot_status": "paused",
},
})
if err != nil {
t.Fatalf("Failed to insert robot_test_inactive_003: %v", err)
}
}
// cleanupTestRobots removes test robot records
func cleanupTestRobots(t *testing.T) {
qb := capsule.Query()
// Use the member model to perform soft delete
m := model.Select("__yao.member")
// Delete test robots
m.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: "robot_test_sales_001"},
},
})
m.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: "robot_test_support_002"},
},
})
m.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: "robot_test_inactive_003"},
},
})
// Hard delete from database (cleanup for next test run)
m2 := model.Select("__yao.member")
tableName2 := m2.MetaData.Table.Name
qb.Table(tableName2).Where("member_id", "robot_test_sales_001").Delete()
qb.Table(tableName2).Where("member_id", "robot_test_support_002").Delete()
qb.Table(tableName2).Where("member_id", "robot_test_inactive_003").Delete()
}

115
agent/robot/cache/load.go vendored Normal file
View file

@ -0,0 +1,115 @@
package cache
import (
"fmt"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/agent/robot/types"
)
// memberModel is the model name for member table
// Can be changed via SetMemberModel() during system initialization
var memberModel = "__yao.member"
// memberFields are the fields to select when loading robots
var memberFields = []interface{}{
"id",
"member_id",
"team_id",
"display_name",
"system_prompt",
"robot_status",
"autonomous_mode",
"robot_config",
}
// SetMemberModel sets the member model name
// Call this during system initialization to override the default
func SetMemberModel(model string) {
if model != "" {
memberModel = model
}
}
// Load loads all active robots from database with pagination
// Query: member_type='robot' AND autonomous_mode=true AND status='active'
func (c *Cache) Load(ctx *types.Context) error {
m := model.Select(memberModel)
// Clear existing cache first
c.mu.Lock()
c.robots = make(map[string]*types.Robot)
c.byTeam = make(map[string][]string)
c.mu.Unlock()
// Paginate to handle large number of robots
page := 1
pageSize := 100 // load 100 robots per page
totalLoaded := 0
for {
// Query with pagination
result, err := m.Paginate(model.QueryParam{
Select: memberFields,
Wheres: []model.QueryWhere{
{Column: "member_type", Value: "robot"},
{Column: "autonomous_mode", Value: true},
{Column: "status", Value: "active"},
},
}, page, pageSize)
if err != nil {
return fmt.Errorf("failed to load robots (page %d): %w", page, err)
}
// Extract records from pagination result
data, ok := result.Get("data").([]maps.MapStr)
if !ok || len(data) == 0 {
break
}
// Parse and add each robot
for _, record := range data {
robot, err := types.NewRobotFromMap(map[string]interface{}(record))
if err != nil {
// Log error but continue loading other robots
continue
}
c.Add(robot)
totalLoaded++
}
// Check if there are more pages
total, _ := result.Get("total").(int)
if totalLoaded >= total {
break
}
page++
}
return nil
}
// LoadByID loads a single robot from database by member ID
func (c *Cache) LoadByID(ctx *types.Context, memberID string) (*types.Robot, error) {
m := model.Select(memberModel)
records, err := m.Get(model.QueryParam{
Select: memberFields,
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
{Column: "member_type", Value: "robot"},
},
Limit: 1,
})
if err != nil {
return nil, fmt.Errorf("failed to load robot %s: %w", memberID, err)
}
if len(records) == 0 {
return nil, types.ErrRobotNotFound
}
return types.NewRobotFromMap(map[string]interface{}(records[0]))
}

142
agent/robot/cache/refresh.go vendored Normal file
View file

@ -0,0 +1,142 @@
package cache
import (
"sync"
"time"
"github.com/yaoapp/yao/agent/robot/types"
)
// RefreshConfig holds refresh configuration
type RefreshConfig struct {
Interval time.Duration // full refresh interval (default: 1 hour)
}
// DefaultRefreshConfig returns default refresh configuration
func DefaultRefreshConfig() *RefreshConfig {
return &RefreshConfig{
Interval: time.Hour,
}
}
// refreshState holds the refresh goroutine state
type refreshState struct {
ticker *time.Ticker
done chan struct{}
mu sync.Mutex
}
var refresher = &refreshState{}
// Refresh refreshes a single robot's config from database
func (c *Cache) Refresh(ctx *types.Context, memberID string) error {
robot, err := c.LoadByID(ctx, memberID)
if err != nil {
// If robot not found or no longer autonomous, remove from cache
if err == types.ErrRobotNotFound {
c.Remove(memberID)
return nil
}
return err
}
// Check if robot is still active and autonomous
if !robot.AutonomousMode {
c.Remove(memberID)
return nil
}
// Update cache
c.Add(robot)
return nil
}
// StartAutoRefresh starts periodic full refresh
func (c *Cache) StartAutoRefresh(ctx *types.Context, config *RefreshConfig) {
if config == nil {
config = DefaultRefreshConfig()
}
refresher.mu.Lock()
defer refresher.mu.Unlock()
// Stop existing refresher if any
if refresher.done != nil {
close(refresher.done)
}
refresher.ticker = time.NewTicker(config.Interval)
refresher.done = make(chan struct{})
go func() {
for {
select {
case <-refresher.done:
refresher.ticker.Stop()
return
case <-refresher.ticker.C:
// Perform full refresh
_ = c.Load(ctx)
}
}
}()
}
// StopAutoRefresh stops the periodic refresh
func (c *Cache) StopAutoRefresh() {
refresher.mu.Lock()
defer refresher.mu.Unlock()
if refresher.done != nil {
close(refresher.done)
refresher.done = nil
}
}
// RefreshAll reloads all robots from database
func (c *Cache) RefreshAll(ctx *types.Context) error {
return c.Load(ctx)
}
// Count returns the number of cached robots
func (c *Cache) Count() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.robots)
}
// ListAll returns all cached robots (across all teams)
func (c *Cache) ListAll() []*types.Robot {
c.mu.RLock()
defer c.mu.RUnlock()
robots := make([]*types.Robot, 0, len(c.robots))
for _, robot := range c.robots {
robots = append(robots, robot)
}
return robots
}
// GetByStatus returns robots with the specified status
func (c *Cache) GetByStatus(status types.RobotStatus) []*types.Robot {
c.mu.RLock()
defer c.mu.RUnlock()
var robots []*types.Robot
for _, robot := range c.robots {
if robot.Status == status {
robots = append(robots, robot)
}
}
return robots
}
// GetIdle returns all idle robots ready to execute
func (c *Cache) GetIdle() []*types.Robot {
return c.GetByStatus(types.RobotIdle)
}
// GetWorking returns all currently working robots
func (c *Cache) GetWorking() []*types.Robot {
return c.GetByStatus(types.RobotWorking)
}

View file

@ -0,0 +1,34 @@
package dedup
import (
"sync"
"time"
"github.com/yaoapp/yao/agent/robot/types"
)
// Dedup implements types.Dedup interface
// This is a stub implementation for Phase 2
type Dedup struct {
marks map[string]time.Time // key -> expiry time
mu sync.RWMutex
}
// New creates a new dedup instance
func New() *Dedup {
return &Dedup{
marks: make(map[string]time.Time),
}
}
// Check checks if execution should be deduplicated
// Stub: always returns proceed (will be implemented in Phase 3)
func (d *Dedup) Check(ctx *types.Context, memberID string, trigger types.TriggerType) (types.DedupResult, error) {
return types.DedupProceed, nil
}
// Mark marks an execution to prevent duplicates within window
// Stub: does nothing (will be implemented in Phase 3)
func (d *Dedup) Mark(memberID string, trigger types.TriggerType, window time.Duration) {
// Stub: no-op
}

View file

@ -0,0 +1,110 @@
package executor
import (
"sync/atomic"
"time"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/robot/utils"
)
// Executor implements types.Executor interface
// This is a stub implementation for Phase 2
type Executor struct {
delay time.Duration // simulated execution delay
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)
}
// New creates a new executor instance
func New() *Executor {
return &Executor{}
}
// NewWithDelay creates a new executor with simulated delay (for testing)
func NewWithDelay(delay time.Duration) *Executor {
return &Executor{
delay: delay,
}
}
// NewWithCallback creates a new executor with callbacks (for testing concurrency)
func NewWithCallback(delay time.Duration, onStart, onEnd func()) *Executor {
return &Executor{
delay: delay,
onStart: onStart,
onEnd: onEnd,
}
}
// Execute executes a robot through all phases
// Stub: returns empty execution (will be implemented in Phase 3+)
func (e *Executor) Execute(ctx *types.Context, robot *types.Robot, trigger types.TriggerType, data interface{}) (*types.Execution, error) {
// Create execution record first
execID := utils.NewID()
exec := &types.Execution{
ID: execID,
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: trigger,
Status: types.ExecRunning,
Phase: types.PhaseInspiration,
}
// 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) {
return nil, types.ErrQuotaExceeded
}
defer robot.RemoveExecution(execID)
// 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()
}
// Simulate execution delay
if e.delay > 0 {
time.Sleep(e.delay)
}
// Check for simulated failure
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
exec.Status = types.ExecFailed
return exec, nil // return error is optional, we track status
}
// Update execution status
exec.Status = types.ExecCompleted
exec.Phase = types.PhaseLearning
return exec, 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 (for testing)
func (e *Executor) Reset() {
e.execCount.Store(0)
e.currentCount.Store(0)
}

33
agent/robot/job/job.go Normal file
View file

@ -0,0 +1,33 @@
package job
import "github.com/yaoapp/yao/agent/robot/types"
// Create creates a new job for robot execution
// Stub: returns empty job ID (will be implemented in Phase 3)
func Create(ctx *types.Context, exec *types.Execution) (string, error) {
return "", nil
}
// Update updates job status
// Stub: returns nil (will be implemented in Phase 3)
func Update(ctx *types.Context, jobID string, status types.ExecStatus, phase types.Phase) error {
return nil
}
// Log writes a log entry for the execution
// Stub: returns nil (will be implemented in Phase 3)
func Log(ctx *types.Context, jobID string, level string, message string, data map[string]interface{}) error {
return nil
}
// Complete marks job as completed
// Stub: returns nil (will be implemented in Phase 3)
func Complete(ctx *types.Context, jobID string, exec *types.Execution) error {
return nil
}
// Fail marks job as failed
// Stub: returns nil (will be implemented in Phase 3)
func Fail(ctx *types.Context, jobID string, err error) error {
return nil
}

View file

@ -0,0 +1,34 @@
package manager
import (
"time"
"github.com/yaoapp/yao/agent/robot/types"
)
// Manager implements types.Manager interface
// This is a stub implementation for Phase 2
type Manager struct{}
// New creates a new manager instance
func New() *Manager {
return &Manager{}
}
// Start starts the manager and clock ticker
// Stub: returns nil (will be implemented in Phase 3)
func (m *Manager) Start() error {
return nil
}
// Stop stops the manager gracefully
// Stub: returns nil (will be implemented in Phase 3)
func (m *Manager) Stop() error {
return nil
}
// Tick processes a clock tick
// Stub: returns nil (will be implemented in Phase 3)
func (m *Manager) Tick(ctx *types.Context, now time.Time) error {
return nil
}

40
agent/robot/plan/plan.go Normal file
View file

@ -0,0 +1,40 @@
package plan
import (
"time"
"github.com/yaoapp/yao/agent/robot/types"
)
// Plan manages planned tasks/goals for later execution
// This is a stub implementation for Phase 2
type Plan struct{}
// New creates a new plan instance
func New() *Plan {
return &Plan{}
}
// Add adds a task or goal to plan queue
// Stub: returns nil (will be implemented in Phase 11)
func (p *Plan) Add(ctx *types.Context, memberID string, item interface{}, executeAt time.Time) error {
return nil
}
// Remove removes an item from plan queue
// Stub: returns nil (will be implemented in Phase 11)
func (p *Plan) Remove(ctx *types.Context, memberID string, itemID string) error {
return nil
}
// List lists all planned items for a robot
// Stub: returns empty slice (will be implemented in Phase 11)
func (p *Plan) List(ctx *types.Context, memberID string) ([]interface{}, error) {
return []interface{}{}, nil
}
// GetDue returns items that are due for execution
// Stub: returns empty slice (will be implemented in Phase 11)
func (p *Plan) GetDue(ctx *types.Context, now time.Time) ([]interface{}, error) {
return []interface{}{}, nil
}

View file

@ -0,0 +1,312 @@
package pool_test
import (
"context"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/executor"
"github.com/yaoapp/yao/agent/robot/pool"
"github.com/yaoapp/yao/agent/robot/types"
)
// ==================== Goroutine Leak Detection Tests ====================
// getGoroutineCount returns current number of goroutines
func getGoroutineCount() int {
return runtime.NumGoroutine()
}
// waitForGoroutineCount waits for goroutine count to stabilize
func waitForGoroutineCount(target int, timeout time.Duration) int {
deadline := time.Now().Add(timeout)
var count int
for time.Now().Before(deadline) {
count = getGoroutineCount()
if count <= target {
return count
}
runtime.Gosched()
time.Sleep(10 * time.Millisecond)
}
return count
}
// TestPoolNoGoroutineLeak tests that pool doesn't leak goroutines after stop
func TestPoolNoGoroutineLeak(t *testing.T) {
// Get baseline goroutine count
runtime.GC()
time.Sleep(50 * time.Millisecond)
baseline := getGoroutineCount()
// Create and start pool
exec := executor.NewWithDelay(10 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 5,
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
// Verify workers are running
afterStart := getGoroutineCount()
assert.Greater(t, afterStart, baseline, "Should have more goroutines after start")
// Submit some jobs
ctx := types.NewContext(context.Background(), nil)
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
for i := 0; i < 10; i++ {
p.Submit(ctx, robot, types.TriggerClock, nil)
}
// Wait for jobs to complete
time.Sleep(300 * time.Millisecond)
// Stop pool
p.Stop()
// Wait for goroutines to clean up
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
// Allow small variance (test framework goroutines)
assert.LessOrEqual(t, finalCount, baseline+2,
"Goroutine count should return to near baseline after stop (baseline=%d, final=%d)", baseline, finalCount)
}
// TestPoolMultipleStartStop tests no leak with multiple start/stop cycles
func TestPoolMultipleStartStop(t *testing.T) {
runtime.GC()
time.Sleep(50 * time.Millisecond)
baseline := getGoroutineCount()
exec := executor.NewWithDelay(5 * time.Millisecond)
for i := 0; i < 5; i++ {
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 3,
QueueSize: 50,
})
p.SetExecutor(exec)
p.Start()
// Submit a few jobs
ctx := types.NewContext(context.Background(), nil)
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
for j := 0; j < 5; j++ {
p.Submit(ctx, robot, types.TriggerClock, nil)
}
time.Sleep(100 * time.Millisecond)
p.Stop()
}
// Wait for cleanup
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
assert.LessOrEqual(t, finalCount, baseline+2,
"Goroutine count should return to near baseline after multiple cycles (baseline=%d, final=%d)", baseline, finalCount)
}
// TestPoolStopWithoutJobs tests no leak when stopping pool with no jobs submitted
func TestPoolStopWithoutJobs(t *testing.T) {
runtime.GC()
time.Sleep(50 * time.Millisecond)
baseline := getGoroutineCount()
exec := executor.New()
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 10,
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
// Immediately stop without submitting any jobs
p.Stop()
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
assert.LessOrEqual(t, finalCount, baseline+2,
"Goroutine count should return to near baseline (baseline=%d, final=%d)", baseline, finalCount)
}
// TestPoolStopWithPendingJobs tests no leak when stopping with jobs in queue
func TestPoolStopWithPendingJobs(t *testing.T) {
runtime.GC()
time.Sleep(50 * time.Millisecond)
baseline := getGoroutineCount()
// Use slow executor so jobs stay in queue
exec := executor.NewWithDelay(500 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1, // only 1 worker
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
// Submit many jobs (most will be queued)
ctx := types.NewContext(context.Background(), nil)
robot := createTestRobot("robot_1", "team_1", 5, 50, 5)
for i := 0; i < 20; i++ {
p.Submit(ctx, robot, types.TriggerClock, nil)
}
// Stop immediately (some jobs still in queue)
time.Sleep(50 * time.Millisecond)
p.Stop()
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
assert.LessOrEqual(t, finalCount, baseline+2,
"Goroutine count should return to near baseline even with pending jobs (baseline=%d, final=%d)", baseline, finalCount)
}
// TestPoolConcurrentStartStop tests no leak with concurrent start/stop
func TestPoolConcurrentStartStop(t *testing.T) {
runtime.GC()
time.Sleep(50 * time.Millisecond)
baseline := getGoroutineCount()
exec := executor.NewWithDelay(10 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 5,
QueueSize: 100,
})
p.SetExecutor(exec)
// Start pool
p.Start()
// Concurrent operations
done := make(chan bool, 3)
// Goroutine 1: Submit jobs
go func() {
ctx := types.NewContext(context.Background(), nil)
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
for i := 0; i < 20; i++ {
p.Submit(ctx, robot, types.TriggerClock, nil)
time.Sleep(5 * time.Millisecond)
}
done <- true
}()
// Goroutine 2: Check status
go func() {
for i := 0; i < 20; i++ {
_ = p.Running()
_ = p.Queued()
time.Sleep(5 * time.Millisecond)
}
done <- true
}()
// Wait for operations
<-done
<-done
// Stop pool
p.Stop()
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
assert.LessOrEqual(t, finalCount, baseline+2,
"Goroutine count should return to near baseline after concurrent ops (baseline=%d, final=%d)", baseline, finalCount)
}
// TestWorkerGoroutinesCleanup tests that worker goroutines are properly cleaned up
func TestWorkerGoroutinesCleanup(t *testing.T) {
runtime.GC()
time.Sleep(50 * time.Millisecond)
baseline := getGoroutineCount()
exec := executor.NewWithDelay(10 * time.Millisecond)
// Create pool with many workers
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 20,
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
// Should have baseline + 20 workers
afterStart := getGoroutineCount()
assert.GreaterOrEqual(t, afterStart, baseline+20, "Should have at least 20 worker goroutines")
// Stop pool
p.Stop()
// All worker goroutines should be cleaned up
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
assert.LessOrEqual(t, finalCount, baseline+2,
"All worker goroutines should be cleaned up (baseline=%d, final=%d)", baseline, finalCount)
}
// TestPoolLongRunningJobsNoLeak tests no leak with long-running jobs
func TestPoolLongRunningJobsNoLeak(t *testing.T) {
runtime.GC()
time.Sleep(50 * time.Millisecond)
baseline := getGoroutineCount()
exec := executor.NewWithDelay(200 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 3,
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
// Submit jobs
ctx := types.NewContext(context.Background(), nil)
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
for i := 0; i < 5; i++ {
p.Submit(ctx, robot, types.TriggerClock, nil)
}
// Wait for some jobs to complete
time.Sleep(500 * time.Millisecond)
// Stop pool
p.Stop()
finalCount := waitForGoroutineCount(baseline+2, 500*time.Millisecond)
assert.LessOrEqual(t, finalCount, baseline+2,
"No goroutine leak after long-running jobs (baseline=%d, final=%d)", baseline, finalCount)
}
// TestQueueNoGoroutineLeak tests that queue operations don't leak goroutines
func TestQueueNoGoroutineLeak(t *testing.T) {
runtime.GC()
time.Sleep(50 * time.Millisecond)
baseline := getGoroutineCount()
// Create queue and perform many operations
pq := pool.NewPriorityQueue(1000)
// Enqueue many items
for i := 0; i < 500; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i%26)), "team_1", 5, 100, 5)
pq.Enqueue(&pool.QueueItem{
Robot: robot,
Trigger: types.TriggerClock,
})
}
// Dequeue all items
for pq.Size() > 0 {
pq.Dequeue()
}
runtime.GC()
time.Sleep(50 * time.Millisecond)
finalCount := getGoroutineCount()
assert.LessOrEqual(t, finalCount, baseline+2,
"Queue operations should not leak goroutines (baseline=%d, final=%d)", baseline, finalCount)
}

195
agent/robot/pool/pool.go Normal file
View file

@ -0,0 +1,195 @@
package pool
import (
"fmt"
"sync"
"sync/atomic"
"github.com/yaoapp/yao/agent/robot/types"
)
// Default configuration values
const (
DefaultWorkerSize = 10 // default number of workers
DefaultQueueSize = 100 // default global queue size
)
// Config holds pool configuration
type Config struct {
WorkerSize int // number of workers (default: 10)
QueueSize int // global queue size (default: 100)
}
// DefaultConfig returns default pool configuration
func DefaultConfig() *Config {
return &Config{
WorkerSize: DefaultWorkerSize,
QueueSize: DefaultQueueSize,
}
}
// 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
}
// New creates a new pool instance with default configuration
func New() *Pool {
return NewWithConfig(nil)
}
// NewWithConfig creates a new pool instance with custom configuration
func NewWithConfig(config *Config) *Pool {
if config == nil {
config = DefaultConfig()
}
// Apply defaults for zero values
workerSize := config.WorkerSize
if workerSize <= 0 {
workerSize = DefaultWorkerSize
}
queueSize := config.QueueSize
if queueSize <= 0 {
queueSize = DefaultQueueSize
}
return &Pool{
size: workerSize,
queue: NewPriorityQueue(queueSize),
}
}
// SetExecutor sets the executor for the pool
// Must be called before Start()
func (p *Pool) SetExecutor(executor types.Executor) {
p.executor = executor
}
// Start starts the worker pool
func (p *Pool) Start() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.started {
return fmt.Errorf("pool already started")
}
if p.executor == nil {
return fmt.Errorf("executor not set, call SetExecutor() first")
}
// 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)
p.workers[i] = worker
worker.start()
}
p.started = true
return nil
}
// Stop stops the worker pool gracefully
// Waits for all running jobs to complete
func (p *Pool) Stop() error {
p.mu.Lock()
if !p.started {
p.mu.Unlock()
return nil // already stopped or never started
}
p.started = false
p.mu.Unlock()
// Stop all workers
for _, worker := range p.workers {
worker.stop()
}
// Wait for all workers to finish
p.wg.Wait()
return nil
}
// 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) {
p.mu.RLock()
if !p.started {
p.mu.RUnlock()
return "", fmt.Errorf("pool not started")
}
p.mu.RUnlock()
if robot == nil {
return "", fmt.Errorf("robot cannot be nil")
}
// Create queue item
item := &QueueItem{
Robot: robot,
Ctx: ctx,
Trigger: trigger,
Data: data,
}
// Try to add to queue
if !p.queue.Enqueue(item) {
return "", fmt.Errorf("queue full (max %d items)", p.queue.maxSize)
}
// Generate execution ID for tracking
// Note: Actual execution ID will be generated by Executor
// This is just a placeholder for the Submit return value
execID := fmt.Sprintf("queued_%s_%d", robot.MemberID, item.EnqueueTime.Unix())
return execID, nil
}
// Running returns number of currently running jobs
func (p *Pool) Running() int {
return int(p.running.Load())
}
// Queued returns number of queued jobs
func (p *Pool) Queued() int {
return p.queue.Size()
}
// incrementRunning increments the running counter
func (p *Pool) incrementRunning() {
p.running.Add(1)
}
// decrementRunning decrements the running counter
func (p *Pool) decrementRunning() {
p.running.Add(-1)
}
// Size returns the configured pool size
func (p *Pool) Size() int {
return p.size
}
// QueueSize returns the configured queue size
func (p *Pool) QueueSize() int {
return p.queue.maxSize
}
// IsStarted returns true if the pool has been started
func (p *Pool) IsStarted() bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.started
}

View file

@ -0,0 +1,418 @@
package pool_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/executor"
"github.com/yaoapp/yao/agent/robot/pool"
"github.com/yaoapp/yao/agent/robot/types"
)
// createTestRobot creates a robot for testing with specified quota
func createTestRobot(memberID, teamID string, maxConcurrent, queueSize, priority int) *types.Robot {
return &types.Robot{
MemberID: memberID,
TeamID: teamID,
DisplayName: "Test Robot " + memberID,
Status: types.RobotIdle,
AutonomousMode: true,
Config: &types.Config{
Identity: &types.Identity{Role: "Test"},
Quota: &types.Quota{
Max: maxConcurrent,
Queue: queueSize,
Priority: priority,
},
},
}
}
// createTestContext creates a context for testing
func createTestContext() *types.Context {
return types.NewContext(context.Background(), nil)
}
// TestPoolStartStop tests pool start and stop lifecycle
func TestPoolStartStop(t *testing.T) {
p := pool.New()
exec := executor.New()
p.SetExecutor(exec)
t.Run("start pool", func(t *testing.T) {
err := p.Start()
assert.NoError(t, err)
assert.True(t, p.IsStarted())
})
t.Run("start already started pool", func(t *testing.T) {
err := p.Start()
assert.Error(t, err)
assert.Contains(t, err.Error(), "already started")
})
t.Run("stop pool", func(t *testing.T) {
err := p.Stop()
assert.NoError(t, err)
assert.False(t, p.IsStarted())
})
t.Run("stop already stopped pool", func(t *testing.T) {
err := p.Stop()
assert.NoError(t, err) // should not error
})
}
// TestPoolSubmitWithoutStart tests submitting to unstarted pool
func TestPoolSubmitWithoutStart(t *testing.T) {
p := pool.New()
exec := executor.New()
p.SetExecutor(exec)
robot := createTestRobot("robot_1", "team_1", 2, 10, 5)
ctx := createTestContext()
_, err := p.Submit(ctx, robot, types.TriggerClock, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not started")
}
// TestPoolSubmitNilRobot tests submitting nil robot
func TestPoolSubmitNilRobot(t *testing.T) {
p := pool.New()
exec := executor.New()
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
_, err := p.Submit(ctx, nil, types.TriggerClock, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "cannot be nil")
}
// TestPoolBasicExecution tests basic job execution
func TestPoolBasicExecution(t *testing.T) {
exec := executor.NewWithDelay(50 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 5,
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
robot := createTestRobot("robot_1", "team_1", 2, 10, 5)
ctx := createTestContext()
// Submit a job
execID, err := p.Submit(ctx, robot, types.TriggerClock, nil)
assert.NoError(t, err)
assert.NotEmpty(t, execID)
// Wait for execution
time.Sleep(200 * time.Millisecond)
// Verify execution completed
assert.Equal(t, 1, exec.ExecCount())
assert.Equal(t, 0, exec.CurrentCount())
}
// TestPoolConcurrencyLimit tests global worker limit
func TestPoolConcurrencyLimit(t *testing.T) {
exec := executor.NewWithDelay(200 * time.Millisecond) // longer delay
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 3, // only 3 workers
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Create robots with high quota (won't be the bottleneck)
robots := make([]*types.Robot, 10)
for i := 0; i < 10; i++ {
robots[i] = createTestRobot(
"robot_"+string(rune('A'+i)),
"team_1",
5, // max concurrent per robot
20, // queue size per robot
5, // priority
)
}
// Submit 10 jobs
for i := 0; i < 10; i++ {
_, err := p.Submit(ctx, robots[i], types.TriggerClock, nil)
assert.NoError(t, err)
}
// Wait for workers to pick up jobs (worker polls every 100ms)
time.Sleep(150 * time.Millisecond)
// Should have at most 3 running (worker limit)
running := p.Running()
assert.LessOrEqual(t, running, 3, "Should not exceed worker limit")
// Wait for all to complete
time.Sleep(800 * time.Millisecond)
assert.Equal(t, 10, exec.ExecCount())
}
// TestRobotConcurrencyLimit tests per-robot concurrent execution limit
func TestRobotConcurrencyLimit(t *testing.T) {
exec := executor.NewWithDelay(100 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 10, // plenty of workers
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Create robot with Max=2 (can only run 2 at a time)
robot := createTestRobot("robot_limited", "team_1", 2, 20, 5)
// Submit 5 jobs for the same robot
for i := 0; i < 5; i++ {
_, err := p.Submit(ctx, robot, types.TriggerClock, nil)
assert.NoError(t, err)
}
// Wait a bit for execution to start
time.Sleep(150 * time.Millisecond)
// Robot should have at most 2 running (Quota.Max=2)
runningCount := robot.RunningCount()
assert.LessOrEqual(t, runningCount, 2, "Robot should not exceed Quota.Max")
// Wait for all to complete (with re-enqueue, need more time)
// 5 jobs with Max=2: ~3 batches * 100ms exec + poll overhead
time.Sleep(800 * time.Millisecond)
// All 5 jobs should eventually execute
assert.GreaterOrEqual(t, exec.ExecCount(), 5, "All jobs should eventually execute")
}
// TestRobotQueueLimit tests per-robot queue limit
func TestRobotQueueLimit(t *testing.T) {
exec := executor.NewWithDelay(200 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 2,
QueueSize: 100, // global queue is large
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Create robot with small queue limit
robot := createTestRobot("robot_small_queue", "team_1", 1, 3, 5) // Queue=3
// Submit jobs until queue limit is reached
successCount := 0
for i := 0; i < 10; i++ {
_, err := p.Submit(ctx, robot, types.TriggerClock, nil)
if err == nil {
successCount++
}
}
// Should only accept up to Queue limit (some may have started executing)
// Max accepted = Queue(3) + Max(1) = 4 (1 running + 3 in queue)
assert.LessOrEqual(t, successCount, 4, "Should respect robot queue limit")
assert.GreaterOrEqual(t, successCount, 1, "Should accept at least 1 job")
}
// TestGlobalQueueLimit tests global queue limit
func TestGlobalQueueLimit(t *testing.T) {
exec := executor.NewWithDelay(500 * time.Millisecond) // slow execution
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1, // only 1 worker
QueueSize: 5, // small global queue
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Create multiple robots with large queue limits
successCount := 0
for i := 0; i < 20; i++ {
robot := createTestRobot(
"robot_"+string(rune('A'+i%26)),
"team_1",
5, // large max
20, // large per-robot queue
5,
)
_, err := p.Submit(ctx, robot, types.TriggerClock, nil)
if err == nil {
successCount++
}
}
// Should only accept up to global queue limit + running
// Max = QueueSize(5) + WorkerSize(1) = 6
assert.LessOrEqual(t, successCount, 6, "Should respect global queue limit")
}
// TestPriorityOrder tests that higher priority jobs execute first
func TestPriorityOrder(t *testing.T) {
exec := executor.NewWithDelay(50 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1, // single worker to ensure order
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Create robots with different priorities
robotLow := createTestRobot("robot_low", "team_1", 5, 20, 1) // priority 1
robotMed := createTestRobot("robot_med", "team_1", 5, 20, 5) // priority 5
robotHigh := createTestRobot("robot_high", "team_1", 5, 20, 10) // priority 10
// Submit in low-to-high order
p.Submit(ctx, robotLow, types.TriggerClock, nil)
p.Submit(ctx, robotMed, types.TriggerClock, nil)
p.Submit(ctx, robotHigh, types.TriggerClock, nil)
// Wait for all to complete
time.Sleep(400 * time.Millisecond)
// Verify all executed
assert.Equal(t, 3, exec.ExecCount())
}
// TestTriggerTypePriority tests that human triggers have higher priority than clock
func TestTriggerTypePriority(t *testing.T) {
exec := executor.NewWithDelay(50 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1, // single worker
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Same robot, same priority, different trigger types
robot := createTestRobot("robot_1", "team_1", 5, 20, 5)
// Submit clock first, then human
p.Submit(ctx, robot, types.TriggerClock, nil)
p.Submit(ctx, robot, types.TriggerHuman, nil) // should execute first
time.Sleep(300 * time.Millisecond)
assert.Equal(t, 2, exec.ExecCount())
}
// TestMultipleRobotsFairness tests that multiple robots get fair access
func TestMultipleRobotsFairness(t *testing.T) {
exec := executor.NewWithDelay(30 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 5,
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Create 3 robots with same priority
robotA := createTestRobot("robot_A", "team_1", 2, 10, 5)
robotB := createTestRobot("robot_B", "team_1", 2, 10, 5)
robotC := createTestRobot("robot_C", "team_1", 2, 10, 5)
// Submit jobs for each robot
for i := 0; i < 6; i++ {
p.Submit(ctx, robotA, types.TriggerClock, nil)
p.Submit(ctx, robotB, types.TriggerClock, nil)
p.Submit(ctx, robotC, types.TriggerClock, nil)
}
// Wait for all to complete
time.Sleep(500 * time.Millisecond)
// All 18 jobs should complete
assert.Equal(t, 18, exec.ExecCount())
}
// TestGracefulShutdown tests that pool waits for running jobs on shutdown
func TestGracefulShutdown(t *testing.T) {
exec := executor.NewWithDelay(200 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 2,
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
ctx := createTestContext()
robot := createTestRobot("robot_1", "team_1", 5, 20, 5)
// Submit 2 jobs
p.Submit(ctx, robot, types.TriggerClock, nil)
p.Submit(ctx, robot, types.TriggerClock, nil)
// Wait for workers to pick up jobs (poll every 100ms)
time.Sleep(150 * time.Millisecond)
// Verify jobs are running
assert.GreaterOrEqual(t, p.Running(), 1, "Should have at least 1 running job")
// Stop - workers will finish their current tick cycle
p.Stop()
// After stop, verify jobs completed
assert.GreaterOrEqual(t, exec.ExecCount(), 1, "Should have executed at least 1 job")
}
// TestDefaultConfig tests default configuration values
func TestDefaultConfig(t *testing.T) {
config := pool.DefaultConfig()
assert.Equal(t, pool.DefaultWorkerSize, config.WorkerSize)
assert.Equal(t, pool.DefaultQueueSize, config.QueueSize)
}
// TestPoolWithNilConfig tests pool creation with nil config
func TestPoolWithNilConfig(t *testing.T) {
p := pool.NewWithConfig(nil)
assert.Equal(t, pool.DefaultWorkerSize, p.Size())
assert.Equal(t, pool.DefaultQueueSize, p.QueueSize())
}
// TestPoolWithZeroConfig tests pool creation with zero values
func TestPoolWithZeroConfig(t *testing.T) {
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 0,
QueueSize: 0,
})
// Should use defaults for zero values
assert.Equal(t, pool.DefaultWorkerSize, p.Size())
assert.Equal(t, pool.DefaultQueueSize, p.QueueSize())
}
// TestPoolWithoutExecutor tests starting pool without executor
func TestPoolWithoutExecutor(t *testing.T) {
p := pool.New()
// Don't set executor
err := p.Start()
assert.Error(t, err)
assert.Contains(t, err.Error(), "executor not set")
}

201
agent/robot/pool/queue.go Normal file
View file

@ -0,0 +1,201 @@
package pool
import (
"container/heap"
"sync"
"time"
"github.com/yaoapp/yao/agent/robot/types"
)
// 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)
}
// PriorityQueue implements a priority queue for robot executions
// Sorted by: robot priority > trigger type priority > wait time
type PriorityQueue struct {
items []*QueueItem
mu sync.RWMutex
maxSize int // global queue size limit
robotCount map[string]int // per-robot queue count: memberID -> count
}
// NewPriorityQueue creates a new priority queue
func NewPriorityQueue(maxSize int) *PriorityQueue {
pq := &PriorityQueue{
items: make([]*QueueItem, 0),
maxSize: maxSize,
robotCount: make(map[string]int),
}
heap.Init(pq)
return pq
}
// Enqueue adds an item to the queue
// Returns false if:
// - Global queue is full (maxSize)
// - Robot's queue limit reached (Quota.Queue)
func (pq *PriorityQueue) Enqueue(item *QueueItem) bool {
pq.mu.Lock()
defer pq.mu.Unlock()
// Check 1: Global queue limit
if pq.maxSize > 0 && len(pq.items) >= pq.maxSize {
return false // global queue full
}
// Check 2: Per-robot queue limit (prevents single robot from hogging the queue)
if item.Robot != nil {
memberID := item.Robot.MemberID
robotQueueLimit := 10 // default
if item.Robot.Config != nil && item.Robot.Config.Quota != nil {
robotQueueLimit = item.Robot.Config.Quota.GetQueue()
}
if pq.robotCount[memberID] >= robotQueueLimit {
return false // robot's queue limit reached
}
// Increment robot's queue count
pq.robotCount[memberID]++
}
item.Priority = calculatePriority(item)
item.EnqueueTime = time.Now()
heap.Push(pq, item)
return true
}
// Dequeue removes and returns the highest priority item
// Returns nil if queue is empty
func (pq *PriorityQueue) Dequeue() *QueueItem {
pq.mu.Lock()
defer pq.mu.Unlock()
if len(pq.items) == 0 {
return nil
}
item := heap.Pop(pq).(*QueueItem)
// Decrement robot's queue count
if item.Robot != nil {
memberID := item.Robot.MemberID
if pq.robotCount[memberID] > 0 {
pq.robotCount[memberID]--
}
// Clean up if count reaches zero
if pq.robotCount[memberID] == 0 {
delete(pq.robotCount, memberID)
}
}
return item
}
// Size returns the number of items in the queue (thread-safe)
func (pq *PriorityQueue) Size() int {
pq.mu.RLock()
defer pq.mu.RUnlock()
return len(pq.items)
}
// IsFull returns true if queue has reached max capacity
func (pq *PriorityQueue) IsFull() bool {
pq.mu.RLock()
defer pq.mu.RUnlock()
return pq.maxSize > 0 && len(pq.items) >= pq.maxSize
}
// RobotQueuedCount returns the number of queued items for a specific robot
func (pq *PriorityQueue) RobotQueuedCount(memberID string) int {
pq.mu.RLock()
defer pq.mu.RUnlock()
return pq.robotCount[memberID]
}
// ==================== heap.Interface implementation ====================
// These methods are called internally by heap.Push/Pop with lock already held
func (pq *PriorityQueue) Len() int { return len(pq.items) }
func (pq *PriorityQueue) Less(i, j int) bool {
// Higher priority value = higher priority (processed first)
// If priority is equal, older items (earlier EnqueueTime) come first
if pq.items[i].Priority == pq.items[j].Priority {
return pq.items[i].EnqueueTime.Before(pq.items[j].EnqueueTime)
}
return pq.items[i].Priority > pq.items[j].Priority
}
func (pq *PriorityQueue) Swap(i, j int) {
pq.items[i], pq.items[j] = pq.items[j], pq.items[i]
pq.items[i].Index = i
pq.items[j].Index = j
}
// Push is required by heap.Interface
// Note: This is called by heap.Push(), not directly
func (pq *PriorityQueue) Push(x interface{}) {
item := x.(*QueueItem)
item.Index = len(pq.items)
pq.items = append(pq.items, item)
}
// Pop is required by heap.Interface
// Note: This is called by heap.Pop(), not directly
func (pq *PriorityQueue) Pop() interface{} {
old := pq.items
n := len(old)
item := old[n-1]
old[n-1] = nil // avoid memory leak
item.Index = -1 // mark as removed
pq.items = old[0 : n-1]
return item
}
// ==================== Priority Calculation ====================
// calculatePriority calculates the priority score for a queue item
// Priority = robot_priority * 1000 + trigger_priority * 100
// Higher score = higher priority
func calculatePriority(item *QueueItem) int {
priority := 0
// 1. Robot priority (from config, 1-10, default 5)
if item.Robot != nil && item.Robot.Config != nil && item.Robot.Config.Quota != nil {
robotPriority := item.Robot.Config.Quota.GetPriority()
priority += robotPriority * 1000
} else {
priority += 5000 // default robot priority
}
// 2. Trigger type priority
// Human intervention > Event > Clock
triggerPriority := getTriggerPriority(item.Trigger)
priority += triggerPriority * 100
return priority
}
// getTriggerPriority returns priority value for trigger type
func getTriggerPriority(trigger types.TriggerType) int {
switch trigger {
case types.TriggerHuman:
return 10 // highest priority
case types.TriggerEvent:
return 5 // medium priority
case types.TriggerClock:
return 1 // lowest priority
default:
return 0
}
}

View file

@ -0,0 +1,510 @@
package pool_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/pool"
"github.com/yaoapp/yao/agent/robot/types"
)
// ==================== Priority Queue Basic Tests ====================
// TestQueueNewPriorityQueue tests queue creation
func TestQueueNewPriorityQueue(t *testing.T) {
t.Run("create with positive size", func(t *testing.T) {
pq := pool.NewPriorityQueue(100)
assert.NotNil(t, pq)
assert.Equal(t, 0, pq.Size())
assert.False(t, pq.IsFull())
})
t.Run("create with zero size (unlimited)", func(t *testing.T) {
pq := pool.NewPriorityQueue(0)
assert.NotNil(t, pq)
assert.False(t, pq.IsFull()) // never full when maxSize=0
})
}
// TestQueueEnqueueDequeue tests basic enqueue and dequeue
func TestQueueEnqueueDequeue(t *testing.T) {
pq := pool.NewPriorityQueue(100)
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
ctx := createTestContext()
t.Run("enqueue single item", func(t *testing.T) {
item := &pool.QueueItem{
Robot: robot,
Ctx: ctx,
Trigger: types.TriggerClock,
Data: "test_data",
}
ok := pq.Enqueue(item)
assert.True(t, ok)
assert.Equal(t, 1, pq.Size())
})
t.Run("dequeue single item", func(t *testing.T) {
item := pq.Dequeue()
assert.NotNil(t, item)
assert.Equal(t, "robot_1", item.Robot.MemberID)
assert.Equal(t, "test_data", item.Data)
assert.Equal(t, 0, pq.Size())
})
t.Run("dequeue from empty queue", func(t *testing.T) {
item := pq.Dequeue()
assert.Nil(t, item)
})
}
// TestQueueSize tests Size method
func TestQueueSize(t *testing.T) {
pq := pool.NewPriorityQueue(100)
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
assert.Equal(t, 0, pq.Size())
// Add 5 items
for i := 0; i < 5; i++ {
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
}
assert.Equal(t, 5, pq.Size())
// Remove 2 items
pq.Dequeue()
pq.Dequeue()
assert.Equal(t, 3, pq.Size())
}
// ==================== Global Queue Limit Tests ====================
// TestQueueGlobalLimit tests global queue size limit
func TestQueueGlobalLimit(t *testing.T) {
pq := pool.NewPriorityQueue(5) // max 5 items
// Create different robots to avoid per-robot limit
for i := 0; i < 10; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
item := &pool.QueueItem{Robot: robot, Trigger: types.TriggerClock}
ok := pq.Enqueue(item)
if i < 5 {
assert.True(t, ok, "Should accept item %d", i)
} else {
assert.False(t, ok, "Should reject item %d (queue full)", i)
}
}
assert.Equal(t, 5, pq.Size())
assert.True(t, pq.IsFull())
}
// TestQueueUnlimitedSize tests queue with no size limit (maxSize=0)
func TestQueueUnlimitedSize(t *testing.T) {
pq := pool.NewPriorityQueue(0) // unlimited
// Add many items
for i := 0; i < 100; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i%26)), "team_1", 5, 1000, 5)
item := &pool.QueueItem{Robot: robot, Trigger: types.TriggerClock}
ok := pq.Enqueue(item)
assert.True(t, ok)
}
assert.Equal(t, 100, pq.Size())
assert.False(t, pq.IsFull()) // never full
}
// ==================== Per-Robot Queue Limit Tests ====================
// TestQueuePerRobotLimit tests per-robot queue limit (Quota.Queue)
func TestQueuePerRobotLimit(t *testing.T) {
pq := pool.NewPriorityQueue(100) // large global limit
// Robot with Queue=3
robot := createTestRobot("robot_limited", "team_1", 5, 3, 5)
// Try to add 10 items for same robot
successCount := 0
for i := 0; i < 10; i++ {
item := &pool.QueueItem{Robot: robot, Trigger: types.TriggerClock}
if pq.Enqueue(item) {
successCount++
}
}
// Should only accept Queue(3) items
assert.Equal(t, 3, successCount)
assert.Equal(t, 3, pq.Size())
assert.Equal(t, 3, pq.RobotQueuedCount("robot_limited"))
}
// TestQueueMultipleRobotsIndependentLimits tests that each robot has independent queue limit
func TestQueueMultipleRobotsIndependentLimits(t *testing.T) {
pq := pool.NewPriorityQueue(100)
// Robot A: Queue=2
robotA := createTestRobot("robot_A", "team_1", 5, 2, 5)
// Robot B: Queue=3
robotB := createTestRobot("robot_B", "team_1", 5, 3, 5)
// Add items for Robot A
for i := 0; i < 5; i++ {
pq.Enqueue(&pool.QueueItem{Robot: robotA, Trigger: types.TriggerClock})
}
assert.Equal(t, 2, pq.RobotQueuedCount("robot_A"))
// Add items for Robot B
for i := 0; i < 5; i++ {
pq.Enqueue(&pool.QueueItem{Robot: robotB, Trigger: types.TriggerClock})
}
assert.Equal(t, 3, pq.RobotQueuedCount("robot_B"))
// Total in queue
assert.Equal(t, 5, pq.Size())
}
// TestQueueRobotCountAfterDequeue tests robot count decrements after dequeue
func TestQueueRobotCountAfterDequeue(t *testing.T) {
pq := pool.NewPriorityQueue(100)
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Add 3 items
for i := 0; i < 3; i++ {
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
}
assert.Equal(t, 3, pq.RobotQueuedCount("robot_1"))
// Dequeue 2
pq.Dequeue()
assert.Equal(t, 2, pq.RobotQueuedCount("robot_1"))
pq.Dequeue()
assert.Equal(t, 1, pq.RobotQueuedCount("robot_1"))
// Dequeue last
pq.Dequeue()
assert.Equal(t, 0, pq.RobotQueuedCount("robot_1"))
}
// TestQueueNilRobot tests handling of nil robot
func TestQueueNilRobot(t *testing.T) {
pq := pool.NewPriorityQueue(100)
// Item with nil robot should still be enqueued
item := &pool.QueueItem{
Robot: nil,
Trigger: types.TriggerClock,
}
ok := pq.Enqueue(item)
assert.True(t, ok)
assert.Equal(t, 1, pq.Size())
// Dequeue should work
dequeued := pq.Dequeue()
assert.NotNil(t, dequeued)
assert.Nil(t, dequeued.Robot)
}
// TestQueueDefaultRobotQueueLimit tests default queue limit when Quota is nil
func TestQueueDefaultRobotQueueLimit(t *testing.T) {
pq := pool.NewPriorityQueue(100)
// Robot without Config
robot := &types.Robot{
MemberID: "robot_no_config",
TeamID: "team_1",
}
// Should use default queue limit (10)
successCount := 0
for i := 0; i < 15; i++ {
item := &pool.QueueItem{Robot: robot, Trigger: types.TriggerClock}
if pq.Enqueue(item) {
successCount++
}
}
assert.Equal(t, 10, successCount) // default queue limit
}
// ==================== Priority Tests ====================
// TestQueuePriorityByRobotPriority tests sorting by robot priority
func TestQueuePriorityByRobotPriority(t *testing.T) {
pq := pool.NewPriorityQueue(100)
// Add robots with different priorities (low to high)
robotLow := createTestRobot("robot_low", "team_1", 5, 10, 1)
robotMed := createTestRobot("robot_med", "team_1", 5, 10, 5)
robotHigh := createTestRobot("robot_high", "team_1", 5, 10, 10)
// Add in low-to-high order
pq.Enqueue(&pool.QueueItem{Robot: robotLow, Trigger: types.TriggerClock})
pq.Enqueue(&pool.QueueItem{Robot: robotMed, Trigger: types.TriggerClock})
pq.Enqueue(&pool.QueueItem{Robot: robotHigh, Trigger: types.TriggerClock})
// Dequeue should return high priority first
item1 := pq.Dequeue()
assert.Equal(t, "robot_high", item1.Robot.MemberID)
item2 := pq.Dequeue()
assert.Equal(t, "robot_med", item2.Robot.MemberID)
item3 := pq.Dequeue()
assert.Equal(t, "robot_low", item3.Robot.MemberID)
}
// TestQueuePriorityByTriggerType tests sorting by trigger type
func TestQueuePriorityByTriggerType(t *testing.T) {
pq := pool.NewPriorityQueue(100)
// Same robot, different trigger types
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Add in clock -> event -> human order
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerEvent})
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerHuman})
// Dequeue should return human first (highest trigger priority)
item1 := pq.Dequeue()
assert.Equal(t, types.TriggerHuman, item1.Trigger)
item2 := pq.Dequeue()
assert.Equal(t, types.TriggerEvent, item2.Trigger)
item3 := pq.Dequeue()
assert.Equal(t, types.TriggerClock, item3.Trigger)
}
// TestQueuePriorityRobotOverTrigger tests that robot priority > trigger priority
func TestQueuePriorityRobotOverTrigger(t *testing.T) {
pq := pool.NewPriorityQueue(100)
// Low priority robot with human trigger
robotLow := createTestRobot("robot_low", "team_1", 5, 10, 1)
// High priority robot with clock trigger
robotHigh := createTestRobot("robot_high", "team_1", 5, 10, 10)
pq.Enqueue(&pool.QueueItem{Robot: robotLow, Trigger: types.TriggerHuman})
pq.Enqueue(&pool.QueueItem{Robot: robotHigh, Trigger: types.TriggerClock})
// Robot priority (10*1000=10000) > trigger priority (1*1000+10*100=2000)
// So high priority robot should come first even with lower trigger type
item1 := pq.Dequeue()
assert.Equal(t, "robot_high", item1.Robot.MemberID)
item2 := pq.Dequeue()
assert.Equal(t, "robot_low", item2.Robot.MemberID)
}
// TestQueuePriorityByEnqueueTime tests FIFO for same priority
func TestQueuePriorityByEnqueueTime(t *testing.T) {
pq := pool.NewPriorityQueue(100)
// Same robot, same trigger type (same priority)
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Add items with slight delay to ensure different EnqueueTime
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock, Data: "first"})
time.Sleep(1 * time.Millisecond)
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock, Data: "second"})
time.Sleep(1 * time.Millisecond)
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock, Data: "third"})
// Should dequeue in FIFO order (earlier EnqueueTime first)
item1 := pq.Dequeue()
assert.Equal(t, "first", item1.Data)
item2 := pq.Dequeue()
assert.Equal(t, "second", item2.Data)
item3 := pq.Dequeue()
assert.Equal(t, "third", item3.Data)
}
// ==================== Concurrency Tests ====================
// TestQueueConcurrentEnqueue tests concurrent enqueue operations
func TestQueueConcurrentEnqueue(t *testing.T) {
pq := pool.NewPriorityQueue(1000)
// Concurrently add items from multiple goroutines
done := make(chan bool)
for i := 0; i < 10; i++ {
go func(id int) {
robot := createTestRobot("robot_"+string(rune('A'+id)), "team_1", 5, 100, 5)
for j := 0; j < 50; j++ {
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
}
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
// Should have 10 robots * 50 items = 500 items
assert.Equal(t, 500, pq.Size())
}
// TestQueueConcurrentDequeue tests concurrent dequeue operations
func TestQueueConcurrentDequeue(t *testing.T) {
pq := pool.NewPriorityQueue(1000)
// Pre-fill queue
for i := 0; i < 500; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i%10)), "team_1", 5, 100, 5)
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
}
// Concurrently dequeue from multiple goroutines
dequeued := make(chan *pool.QueueItem, 500)
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
for {
item := pq.Dequeue()
if item == nil {
break
}
dequeued <- item
}
done <- true
}()
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
close(dequeued)
// Count dequeued items
count := 0
for range dequeued {
count++
}
assert.Equal(t, 500, count)
assert.Equal(t, 0, pq.Size())
}
// TestQueueConcurrentEnqueueDequeue tests concurrent enqueue and dequeue
func TestQueueConcurrentEnqueueDequeue(t *testing.T) {
pq := pool.NewPriorityQueue(100)
// Run for a short time with concurrent operations
done := make(chan bool)
// Enqueue goroutine
go func() {
for i := 0; i < 200; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i%10)), "team_1", 5, 50, 5)
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
time.Sleep(1 * time.Millisecond)
}
done <- true
}()
// Dequeue goroutine
dequeueCount := 0
go func() {
for i := 0; i < 200; i++ {
if pq.Dequeue() != nil {
dequeueCount++
}
time.Sleep(1 * time.Millisecond)
}
done <- true
}()
// Wait for both
<-done
<-done
// Should have processed some items (exact count depends on timing)
assert.GreaterOrEqual(t, dequeueCount, 1)
}
// ==================== Edge Cases ====================
// TestQueueIsFull tests IsFull method
func TestQueueIsFull(t *testing.T) {
t.Run("not full initially", func(t *testing.T) {
pq := pool.NewPriorityQueue(5)
assert.False(t, pq.IsFull())
})
t.Run("full when at max", func(t *testing.T) {
pq := pool.NewPriorityQueue(3)
for i := 0; i < 3; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
}
assert.True(t, pq.IsFull())
})
t.Run("not full after dequeue", func(t *testing.T) {
pq := pool.NewPriorityQueue(3)
for i := 0; i < 3; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
}
pq.Dequeue()
assert.False(t, pq.IsFull())
})
t.Run("never full when unlimited", func(t *testing.T) {
pq := pool.NewPriorityQueue(0)
for i := 0; i < 100; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i%26)), "team_1", 5, 1000, 5)
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
}
assert.False(t, pq.IsFull())
})
}
// TestQueueRobotQueuedCount tests RobotQueuedCount method
func TestQueueRobotQueuedCount(t *testing.T) {
pq := pool.NewPriorityQueue(100)
t.Run("zero for unknown robot", func(t *testing.T) {
assert.Equal(t, 0, pq.RobotQueuedCount("unknown_robot"))
})
t.Run("correct count for robot", func(t *testing.T) {
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
assert.Equal(t, 2, pq.RobotQueuedCount("robot_1"))
})
t.Run("zero after all dequeued", func(t *testing.T) {
robot := createTestRobot("robot_2", "team_1", 5, 10, 5)
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
pq.Dequeue()
pq.Dequeue() // dequeue robot_1's items too
pq.Dequeue()
assert.Equal(t, 0, pq.RobotQueuedCount("robot_2"))
})
}
// TestQueueEnqueueSetsEnqueueTime tests that EnqueueTime is set on enqueue
func TestQueueEnqueueSetsEnqueueTime(t *testing.T) {
pq := pool.NewPriorityQueue(100)
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
before := time.Now()
pq.Enqueue(&pool.QueueItem{Robot: robot, Trigger: types.TriggerClock})
after := time.Now()
item := pq.Dequeue()
assert.True(t, item.EnqueueTime.After(before) || item.EnqueueTime.Equal(before))
assert.True(t, item.EnqueueTime.Before(after) || item.EnqueueTime.Equal(after))
}

112
agent/robot/pool/worker.go Normal file
View file

@ -0,0 +1,112 @@
package pool
import (
"fmt"
"sync"
"time"
"github.com/yaoapp/yao/agent/robot/types"
)
// Worker represents a worker goroutine that processes jobs
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 {
return &Worker{
id: id,
pool: pool,
executor: executor,
stopChan: make(chan struct{}),
wg: wg,
}
}
// start starts the worker goroutine
func (w *Worker) start() {
w.wg.Add(1)
go w.run()
}
// stop signals the worker to stop
func (w *Worker) stop() {
close(w.stopChan)
}
// run is the main worker loop
func (w *Worker) run() {
defer w.wg.Done()
ticker := time.NewTicker(100 * time.Millisecond) // poll queue every 100ms
defer ticker.Stop()
for {
select {
case <-w.stopChan:
return
case <-ticker.C:
// Try to get a job from the queue
item := w.pool.queue.Dequeue()
if item == nil {
continue // queue empty, wait for next tick
}
// Execute the job
w.execute(item)
}
}
}
// execute processes a single queue item
func (w *Worker) execute(item *QueueItem) {
// Pre-check if robot can run (non-atomic, just for early rejection)
// The actual atomic check happens inside Executor.Execute() via TryAcquireSlot()
if !item.Robot.CanRun() {
// Robot likely at quota, re-enqueue for later
w.requeue(item, "quota pre-check failed")
return
}
// Mark as running (only when actually executing)
w.pool.incrementRunning()
defer w.pool.decrementRunning()
// 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)
if err != nil {
// Check if it's a quota error (race condition - another worker got the slot)
if err == types.ErrQuotaExceeded {
w.requeue(item, "quota exceeded (race)")
return
}
fmt.Printf("Worker %d: Execution failed for robot %s: %v\n",
w.id, item.Robot.MemberID, err)
return
}
if execution != nil {
fmt.Printf("Worker %d: Execution %s completed for robot %s (status: %s)\n",
w.id, execution.ID, item.Robot.MemberID, execution.Status)
}
}
// requeue attempts to put the item back in the queue
func (w *Worker) requeue(item *QueueItem, reason string) {
// Queue length is our system load threshold:
// - If queue has space: task waits for robot quota
// - If queue is full: system is overloaded, drop task
if !w.pool.queue.Enqueue(item) {
// Queue full = system overloaded, drop task (protective discard)
fmt.Printf("Worker %d: Task for robot %s dropped (queue full, %s)\n",
w.id, item.Robot.MemberID, reason)
}
}

View file

@ -0,0 +1,435 @@
package pool_test
import (
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/executor"
"github.com/yaoapp/yao/agent/robot/pool"
"github.com/yaoapp/yao/agent/robot/types"
)
// ==================== Worker Basic Tests ====================
// TestWorkerExecutesJob tests that worker executes a job from queue
func TestWorkerExecutesJob(t *testing.T) {
exec := executor.NewWithDelay(10 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1,
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Submit job
p.Submit(ctx, robot, types.TriggerClock, nil)
// Wait for execution
time.Sleep(200 * time.Millisecond)
assert.Equal(t, 1, exec.ExecCount())
}
// TestWorkerMultipleJobs tests worker processes multiple jobs sequentially
func TestWorkerMultipleJobs(t *testing.T) {
exec := executor.NewWithDelay(20 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1, // single worker
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
robot := createTestRobot("robot_1", "team_1", 10, 10, 5)
// Submit 3 jobs
for i := 0; i < 3; i++ {
p.Submit(ctx, robot, types.TriggerClock, nil)
}
// Wait for all executions (worker polls every 100ms, each job takes 20ms)
// Need: 3 polls * 100ms + 3 jobs * 20ms = ~360ms, add buffer
time.Sleep(500 * time.Millisecond)
assert.Equal(t, 3, exec.ExecCount())
}
// ==================== Worker Quota Check Tests ====================
// 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)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 5, // multiple workers
QueueSize: 20,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Robot can only run 2 at a time
robot := createTestRobot("robot_limited", "team_1", 2, 10, 5)
// Submit 5 jobs for same robot
for i := 0; i < 5; i++ {
p.Submit(ctx, robot, types.TriggerClock, nil)
}
// Wait for all to complete
// With Quota.Max=2, jobs execute in batches: 2+2+1 = 3 batches
// Each batch: 100ms exec + 100ms poll = ~200ms, total ~600ms, add buffer
time.Sleep(1000 * time.Millisecond)
// All should eventually execute
assert.GreaterOrEqual(t, exec.ExecCount(), 5, "All jobs should eventually execute")
}
// TestWorkerReenqueueOnQuotaFull tests that jobs are re-enqueued when quota is full
func TestWorkerReenqueueOnQuotaFull(t *testing.T) {
exec := executor.NewWithDelay(100 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 3,
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Robot can only run 1 at a time, but large queue
robot := createTestRobot("robot_1", "team_1", 1, 50, 5)
// Submit 5 jobs
for i := 0; i < 5; i++ {
p.Submit(ctx, robot, types.TriggerClock, nil)
}
// Wait for all to complete
time.Sleep(600 * time.Millisecond)
// All 5 should eventually execute
assert.Equal(t, 5, exec.ExecCount())
}
// ==================== Worker Concurrency Tests ====================
// TestWorkersConcurrentExecution tests multiple workers execute concurrently
func TestWorkersConcurrentExecution(t *testing.T) {
// Track max concurrent executions
var maxConcurrent int32
var currentConcurrent int32
exec := executor.NewWithCallback(100*time.Millisecond, func() {
current := atomic.AddInt32(&currentConcurrent, 1)
// Update max if current is higher
for {
max := atomic.LoadInt32(&maxConcurrent)
if current <= max || atomic.CompareAndSwapInt32(&maxConcurrent, max, current) {
break
}
}
}, func() {
atomic.AddInt32(&currentConcurrent, -1)
})
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 5, // 5 workers
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Submit 10 jobs for different robots
for i := 0; i < 10; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
p.Submit(ctx, robot, types.TriggerClock, nil)
}
// Wait for execution
time.Sleep(400 * time.Millisecond)
// Should have had concurrent execution (max > 1)
assert.GreaterOrEqual(t, atomic.LoadInt32(&maxConcurrent), int32(2), "Should have concurrent execution")
}
// TestWorkersDoNotExceedPoolSize tests workers don't exceed pool size
func TestWorkersDoNotExceedPoolSize(t *testing.T) {
var maxConcurrent int32
var currentConcurrent int32
var mu sync.Mutex
exec := executor.NewWithCallback(50*time.Millisecond, func() {
mu.Lock()
currentConcurrent++
if currentConcurrent > maxConcurrent {
maxConcurrent = currentConcurrent
}
mu.Unlock()
}, func() {
mu.Lock()
currentConcurrent--
mu.Unlock()
})
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 3, // only 3 workers
QueueSize: 100,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Submit 20 jobs
for i := 0; i < 20; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
p.Submit(ctx, robot, types.TriggerClock, nil)
}
// Wait for all to complete
time.Sleep(500 * time.Millisecond)
// Max concurrent should not exceed worker size
assert.LessOrEqual(t, maxConcurrent, int32(3), "Should not exceed worker size")
}
// ==================== Worker Stop Tests ====================
// TestWorkerStopsGracefully tests worker stops when signaled
func TestWorkerStopsGracefully(t *testing.T) {
exec := executor.NewWithDelay(50 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 2,
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
ctx := createTestContext()
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Submit jobs
p.Submit(ctx, robot, types.TriggerClock, nil)
p.Submit(ctx, robot, types.TriggerClock, nil)
// Wait for jobs to start
time.Sleep(150 * time.Millisecond)
// Stop pool
err := p.Stop()
assert.NoError(t, err)
// Pool should be stopped
assert.False(t, p.IsStarted())
}
// TestWorkerCompletesCurrentJobOnStop tests worker completes current job before stopping
func TestWorkerCompletesCurrentJobOnStop(t *testing.T) {
exec := executor.NewWithDelay(100 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1,
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
ctx := createTestContext()
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Submit job
p.Submit(ctx, robot, types.TriggerClock, nil)
// Wait for job to start
time.Sleep(150 * time.Millisecond)
// Stop pool - should wait for current job
p.Stop()
// Job should have completed
assert.GreaterOrEqual(t, exec.ExecCount(), 1)
}
// ==================== Worker Error Handling Tests ====================
// TestWorkerHandlesExecutorError tests worker continues after executor error
func TestWorkerHandlesExecutorError(t *testing.T) {
exec := executor.NewWithDelay(10 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1,
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Submit job that will fail (using special data)
p.Submit(ctx, robot, types.TriggerClock, "simulate_failure")
// Submit another job that should succeed
p.Submit(ctx, robot, types.TriggerClock, nil)
// Wait for execution
time.Sleep(300 * time.Millisecond)
// Both should have been attempted
assert.GreaterOrEqual(t, exec.ExecCount(), 2)
}
// ==================== Worker Running Counter Tests ====================
// TestWorkerRunningCounterAccurate tests running counter is accurate
func TestWorkerRunningCounterAccurate(t *testing.T) {
exec := executor.NewWithDelay(100 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 3,
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
// Submit jobs for different robots
for i := 0; i < 3; i++ {
robot := createTestRobot("robot_"+string(rune('A'+i)), "team_1", 5, 10, 5)
p.Submit(ctx, robot, types.TriggerClock, nil)
}
// Wait for jobs to start
time.Sleep(150 * time.Millisecond)
// Running should be > 0
running := p.Running()
assert.GreaterOrEqual(t, running, 1)
// Wait for completion
time.Sleep(200 * time.Millisecond)
// Running should be 0 after completion
assert.Equal(t, 0, p.Running())
}
// TestWorkerRunningCounterDecrementsOnError tests running counter decrements on error
func TestWorkerRunningCounterDecrementsOnError(t *testing.T) {
exec := executor.NewWithDelay(10 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1,
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Submit failing job
p.Submit(ctx, robot, types.TriggerClock, "simulate_failure")
// Wait for execution
time.Sleep(200 * time.Millisecond)
// Running should be 0 (decremented even on error)
assert.Equal(t, 0, p.Running())
}
// ==================== Worker with Different Trigger Types ====================
// TestWorkerProcessesDifferentTriggers tests worker handles all trigger types
func TestWorkerProcessesDifferentTriggers(t *testing.T) {
exec := executor.NewWithDelay(10 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1,
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Submit different trigger types
p.Submit(ctx, robot, types.TriggerClock, nil)
p.Submit(ctx, robot, types.TriggerHuman, nil)
p.Submit(ctx, robot, types.TriggerEvent, nil)
// Wait for execution (worker polls every 100ms, each job takes 10ms)
// Need: 3 polls * 100ms + 3 jobs * 10ms = ~330ms, add buffer
time.Sleep(500 * time.Millisecond)
// All should execute
assert.Equal(t, 3, exec.ExecCount())
}
// ==================== Worker Polling Behavior Tests ====================
// TestWorkerPollsQueuePeriodically tests worker polls queue at regular intervals
func TestWorkerPollsQueuePeriodically(t *testing.T) {
exec := executor.NewWithDelay(10 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1,
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Submit job after pool started
time.Sleep(50 * time.Millisecond)
p.Submit(ctx, robot, types.TriggerClock, nil)
// Worker should pick up job within poll interval (100ms)
time.Sleep(200 * time.Millisecond)
assert.Equal(t, 1, exec.ExecCount())
}
// TestWorkerContinuesAfterEmptyQueue tests worker continues polling after empty queue
func TestWorkerContinuesAfterEmptyQueue(t *testing.T) {
exec := executor.NewWithDelay(10 * time.Millisecond)
p := pool.NewWithConfig(&pool.Config{
WorkerSize: 1,
QueueSize: 10,
})
p.SetExecutor(exec)
p.Start()
defer p.Stop()
ctx := createTestContext()
robot := createTestRobot("robot_1", "team_1", 5, 10, 5)
// Wait with empty queue
time.Sleep(200 * time.Millisecond)
// Submit job
p.Submit(ctx, robot, types.TriggerClock, nil)
// Worker should still be running and pick up job
time.Sleep(200 * time.Millisecond)
assert.Equal(t, 1, exec.ExecCount())
}

53
agent/robot/robot.go Normal file
View file

@ -0,0 +1,53 @@
package robot
import (
"github.com/yaoapp/yao/agent/robot/cache"
"github.com/yaoapp/yao/agent/robot/dedup"
"github.com/yaoapp/yao/agent/robot/executor"
"github.com/yaoapp/yao/agent/robot/manager"
"github.com/yaoapp/yao/agent/robot/plan"
"github.com/yaoapp/yao/agent/robot/pool"
"github.com/yaoapp/yao/agent/robot/store"
"github.com/yaoapp/yao/agent/robot/trigger"
)
var (
// Global instances (will be initialized in Init)
globalManager *manager.Manager
globalCache *cache.Cache
globalPool *pool.Pool
globalDedup *dedup.Dedup
globalStore *store.Store
globalTrigger *trigger.Trigger
globalExecutor *executor.Executor
globalPlan *plan.Plan
)
// Init initializes the robot agent system
// Stub: placeholder (will be implemented in Phase 3)
func Init() error {
// Initialize global instances
globalCache = cache.New()
globalDedup = dedup.New()
globalStore = store.New()
globalPool = pool.New() // Default pool size
globalTrigger = trigger.New()
globalExecutor = executor.New()
globalManager = manager.New()
globalPlan = plan.New()
// TODO Phase 3: Start manager and pool
// return globalManager.Start()
return nil
}
// Shutdown gracefully shuts down the robot agent system
// Stub: placeholder (will be implemented in Phase 3)
func Shutdown() error {
// TODO Phase 3: Stop manager and pool
// if globalManager != nil {
// return globalManager.Stop()
// }
return nil
}

View file

@ -0,0 +1,36 @@
package store
import "github.com/yaoapp/yao/agent/robot/types"
// Store implements types.Store interface
// This is a stub implementation for Phase 2
type Store struct{}
// New creates a new store instance
func New() *Store {
return &Store{}
}
// SaveLearning saves learning entries to private KB
// Stub: returns nil (will be implemented in Phase 9)
func (s *Store) SaveLearning(ctx *types.Context, memberID string, entries []types.LearningEntry) error {
return nil
}
// GetHistory retrieves learning history from private KB
// Stub: returns empty slice (will be implemented in Phase 9)
func (s *Store) GetHistory(ctx *types.Context, memberID string, limit int) ([]types.LearningEntry, error) {
return []types.LearningEntry{}, nil
}
// SearchKB searches knowledge base collections
// Stub: returns empty slice (will be implemented in Phase 4+)
func (s *Store) SearchKB(ctx *types.Context, collections []string, query string) ([]interface{}, error) {
return []interface{}{}, nil
}
// QueryDB queries database models
// Stub: returns empty slice (will be implemented in Phase 4+)
func (s *Store) QueryDB(ctx *types.Context, models []string, query interface{}) ([]interface{}, error) {
return []interface{}{}, nil
}

View file

@ -0,0 +1,48 @@
package trigger
import "github.com/yaoapp/yao/agent/robot/types"
// Trigger handles all trigger sources
// This is a stub implementation for Phase 2
type Trigger struct{}
// New creates a new trigger instance
func New() *Trigger {
return &Trigger{}
}
// Clock processes clock trigger
// Stub: returns nil (will be implemented in Phase 3)
func (t *Trigger) Clock(ctx *types.Context, robot *types.Robot) error {
return nil
}
// Intervene processes human intervention
// Stub: returns empty result (will be implemented in Phase 3)
func (t *Trigger) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
return &types.ExecutionResult{}, nil
}
// Event processes event trigger
// Stub: returns empty result (will be implemented in Phase 3)
func (t *Trigger) Event(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
return &types.ExecutionResult{}, nil
}
// Pause pauses a running execution
// Stub: returns nil (will be implemented in Phase 3)
func (t *Trigger) Pause(ctx *types.Context, execID string) error {
return nil
}
// Resume resumes a paused execution
// Stub: returns nil (will be implemented in Phase 3)
func (t *Trigger) Resume(ctx *types.Context, execID string) error {
return nil
}
// Stop stops a running execution
// Stub: returns nil (will be implemented in Phase 3)
func (t *Trigger) Stop(ctx *types.Context, execID string) error {
return nil
}

View file

@ -0,0 +1,51 @@
package types
import "time"
// ClockContext - time context for P0 inspiration
type ClockContext struct {
Now time.Time `json:"now"`
Hour int `json:"hour"` // 0-23
DayOfWeek string `json:"day_of_week"` // Monday, Tuesday...
DayOfMonth int `json:"day_of_month"` // 1-31
WeekOfYear int `json:"week_of_year"` // 1-52
Month int `json:"month"` // 1-12
Year int `json:"year"`
IsWeekend bool `json:"is_weekend"`
IsMonthStart bool `json:"is_month_start"` // 1st-3rd
IsMonthEnd bool `json:"is_month_end"` // last 3 days
IsQuarterEnd bool `json:"is_quarter_end"`
IsYearEnd bool `json:"is_year_end"`
TZ string `json:"tz"`
}
// NewClockContext creates clock context from time
func NewClockContext(t time.Time, tz string) *ClockContext {
loc := time.Local
if tz != "" {
if l, err := time.LoadLocation(tz); err == nil {
loc = l
}
}
t = t.In(loc)
_, week := t.ISOWeek()
dayOfMonth := t.Day()
lastDay := time.Date(t.Year(), t.Month()+1, 0, 0, 0, 0, 0, loc).Day()
return &ClockContext{
Now: t,
Hour: t.Hour(),
DayOfWeek: t.Weekday().String(),
DayOfMonth: dayOfMonth,
WeekOfYear: week,
Month: int(t.Month()),
Year: t.Year(),
IsWeekend: t.Weekday() == time.Saturday || t.Weekday() == time.Sunday,
IsMonthStart: dayOfMonth <= 3,
IsMonthEnd: dayOfMonth >= lastDay-2,
IsQuarterEnd: (t.Month()%3 == 0) && dayOfMonth >= lastDay-2,
IsYearEnd: t.Month() == 12 && dayOfMonth >= 29,
TZ: loc.String(),
}
}

View file

@ -0,0 +1,171 @@
package types_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/types"
)
func TestNewClockContext(t *testing.T) {
t.Run("basic clock context", func(t *testing.T) {
// Test with a known date: 2024-01-15 14:30:00 (Monday)
testTime := time.Date(2024, 1, 15, 14, 30, 0, 0, time.UTC)
ctx := types.NewClockContext(testTime, "UTC")
assert.Equal(t, 14, ctx.Hour)
assert.Equal(t, "Monday", ctx.DayOfWeek)
assert.Equal(t, 15, ctx.DayOfMonth)
assert.Equal(t, 1, ctx.Month)
assert.Equal(t, 2024, ctx.Year)
assert.False(t, ctx.IsWeekend)
assert.False(t, ctx.IsMonthStart)
assert.False(t, ctx.IsMonthEnd)
assert.False(t, ctx.IsQuarterEnd)
assert.False(t, ctx.IsYearEnd)
})
t.Run("weekend detection", func(t *testing.T) {
// Saturday
saturday := time.Date(2024, 1, 13, 10, 0, 0, 0, time.UTC)
ctx := types.NewClockContext(saturday, "")
assert.True(t, ctx.IsWeekend)
// Sunday
sunday := time.Date(2024, 1, 14, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(sunday, "")
assert.True(t, ctx.IsWeekend)
})
t.Run("month start detection", func(t *testing.T) {
// 1st day
day1 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
ctx := types.NewClockContext(day1, "")
assert.True(t, ctx.IsMonthStart)
// 3rd day
day3 := time.Date(2024, 1, 3, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(day3, "")
assert.True(t, ctx.IsMonthStart)
// 4th day - not month start
day4 := time.Date(2024, 1, 4, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(day4, "")
assert.False(t, ctx.IsMonthStart)
})
t.Run("month end detection", func(t *testing.T) {
// Last day of January (31st)
lastDay := time.Date(2024, 1, 31, 10, 0, 0, 0, time.UTC)
ctx := types.NewClockContext(lastDay, "")
assert.True(t, ctx.IsMonthEnd)
// 29th day of January (31 days total)
day29 := time.Date(2024, 1, 29, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(day29, "")
assert.True(t, ctx.IsMonthEnd)
// 28th day of January - not month end
day28 := time.Date(2024, 1, 28, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(day28, "")
assert.False(t, ctx.IsMonthEnd)
})
t.Run("quarter end detection", func(t *testing.T) {
// March 31 - Q1 end
q1End := time.Date(2024, 3, 31, 10, 0, 0, 0, time.UTC)
ctx := types.NewClockContext(q1End, "")
assert.True(t, ctx.IsQuarterEnd)
// June 30 - Q2 end
q2End := time.Date(2024, 6, 30, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(q2End, "")
assert.True(t, ctx.IsQuarterEnd)
// September 30 - Q3 end
q3End := time.Date(2024, 9, 30, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(q3End, "")
assert.True(t, ctx.IsQuarterEnd)
// December 31 - Q4 end
q4End := time.Date(2024, 12, 31, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(q4End, "")
assert.True(t, ctx.IsQuarterEnd)
// Not quarter end
notQEnd := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(notQEnd, "")
assert.False(t, ctx.IsQuarterEnd)
})
t.Run("year end detection", func(t *testing.T) {
// December 29
dec29 := time.Date(2024, 12, 29, 10, 0, 0, 0, time.UTC)
ctx := types.NewClockContext(dec29, "")
assert.True(t, ctx.IsYearEnd)
// December 31
dec31 := time.Date(2024, 12, 31, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(dec31, "")
assert.True(t, ctx.IsYearEnd)
// December 28 - not year end
dec28 := time.Date(2024, 12, 28, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(dec28, "")
assert.False(t, ctx.IsYearEnd)
// January - not year end
jan1 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(jan1, "")
assert.False(t, ctx.IsYearEnd)
})
t.Run("timezone handling", func(t *testing.T) {
testTime := time.Date(2024, 1, 15, 14, 30, 0, 0, time.UTC)
// With Asia/Shanghai timezone
ctx := types.NewClockContext(testTime, "Asia/Shanghai")
assert.Equal(t, "Asia/Shanghai", ctx.TZ)
// Time should be converted to Shanghai timezone
assert.NotEqual(t, testTime, ctx.Now)
assert.Equal(t, 22, ctx.Hour) // UTC 14:00 = Shanghai 22:00 (UTC+8)
// With invalid timezone - should fall back to local
ctx = types.NewClockContext(testTime, "Invalid/Timezone")
assert.NotEmpty(t, ctx.TZ)
})
t.Run("week of year", func(t *testing.T) {
// First week of 2024
jan1 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
ctx := types.NewClockContext(jan1, "")
assert.Equal(t, 1, ctx.WeekOfYear)
// Mid year
july15 := time.Date(2024, 7, 15, 10, 0, 0, 0, time.UTC)
ctx = types.NewClockContext(july15, "")
assert.Greater(t, ctx.WeekOfYear, 20)
assert.Less(t, ctx.WeekOfYear, 35)
})
}
func TestClockContextFields(t *testing.T) {
// Test all fields are populated correctly
testTime := time.Date(2024, 12, 30, 23, 45, 30, 0, time.UTC)
ctx := types.NewClockContext(testTime, "UTC")
assert.NotZero(t, ctx.Now)
assert.Equal(t, 23, ctx.Hour)
assert.Equal(t, "Monday", ctx.DayOfWeek)
assert.Equal(t, 30, ctx.DayOfMonth)
assert.Equal(t, 1, ctx.WeekOfYear) // Dec 30, 2024 is week 1 of 2025
assert.Equal(t, 12, ctx.Month)
assert.Equal(t, 2024, ctx.Year)
assert.False(t, ctx.IsWeekend) // Monday
assert.False(t, ctx.IsMonthStart)
assert.True(t, ctx.IsMonthEnd)
assert.True(t, ctx.IsQuarterEnd)
assert.True(t, ctx.IsYearEnd)
assert.Equal(t, "UTC", ctx.TZ)
}

252
agent/robot/types/config.go Normal file
View file

@ -0,0 +1,252 @@
package types
import (
"encoding/json"
"time"
)
// 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"`
}
// Validate validates the config
func (c *Config) Validate() error {
if c.Identity == nil || c.Identity.Role == "" {
return ErrMissingIdentity
}
if c.Clock != nil {
if err := c.Clock.Validate(); err != nil {
return err
}
}
return nil
}
// Triggers - trigger enable/disable
type Triggers struct {
Clock *TriggerSwitch `json:"clock,omitempty"`
Intervene *TriggerSwitch `json:"intervene,omitempty"`
Event *TriggerSwitch `json:"event,omitempty"`
}
// TriggerSwitch - trigger enable/disable switch
type TriggerSwitch struct {
Enabled bool `json:"enabled"`
Actions []string `json:"actions,omitempty"` // for intervene
}
// IsEnabled checks if trigger is enabled (default: true)
func (t *Triggers) IsEnabled(typ TriggerType) bool {
if t == nil {
return true
}
switch typ {
case TriggerClock:
return t.Clock == nil || t.Clock.Enabled
case TriggerHuman:
return t.Intervene == nil || t.Intervene.Enabled
case TriggerEvent:
return t.Event == nil || t.Event.Enabled
}
return false
}
// Clock - when to wake up
type Clock struct {
Mode ClockMode `json:"mode"` // times | interval | daemon
Times []string `json:"times,omitempty"` // ["09:00", "14:00"]
Days []string `json:"days,omitempty"` // ["Mon", "Tue"] or ["*"]
Every string `json:"every,omitempty"` // "30m", "1h"
TZ string `json:"tz,omitempty"` // "Asia/Shanghai"
Timeout string `json:"timeout,omitempty"` // "30m"
}
// Validate validates clock config
func (c *Clock) Validate() error {
switch c.Mode {
case ClockTimes:
if len(c.Times) == 0 {
return ErrClockTimesEmpty
}
case ClockInterval:
if c.Every == "" {
return ErrClockIntervalEmpty
}
case ClockDaemon:
// no extra validation
default:
return ErrClockModeInvalid
}
return nil
}
// GetTimeout returns parsed timeout duration
func (c *Clock) GetTimeout() time.Duration {
if c.Timeout == "" {
return 30 * time.Minute // default
}
d, err := time.ParseDuration(c.Timeout)
if err != nil {
return 30 * time.Minute
}
return d
}
// GetLocation returns timezone location
func (c *Clock) GetLocation() *time.Location {
if c.TZ == "" {
return time.Local
}
loc, err := time.LoadLocation(c.TZ)
if err != nil {
return time.Local
}
return loc
}
// Identity - who is this robot
type Identity struct {
Role string `json:"role"`
Duties []string `json:"duties,omitempty"`
Rules []string `json:"rules,omitempty"`
}
// Quota - concurrency limits
type Quota struct {
Max int `json:"max"` // max running (default: 2)
Queue int `json:"queue"` // queue size (default: 10)
Priority int `json:"priority"` // 1-10 (default: 5)
}
// GetMax returns max with default
func (q *Quota) GetMax() int {
if q == nil || q.Max <= 0 {
return 2
}
return q.Max
}
// GetQueue returns queue size with default
func (q *Quota) GetQueue() int {
if q == nil || q.Queue <= 0 {
return 10
}
return q.Queue
}
// GetPriority returns priority with default
func (q *Quota) GetPriority() int {
if q == nil || q.Priority <= 0 {
return 5
}
return q.Priority
}
// KB - knowledge base config (same as assistant, from store/types)
// Shared KB collections accessible by this robot
type KB struct {
Collections []string `json:"collections,omitempty"` // KB collection IDs
Options map[string]interface{} `json:"options,omitempty"`
}
// DB - database config (same as assistant, from store/types)
// Shared database models accessible by this robot
type DB struct {
Models []string `json:"models,omitempty"` // database model names
Options map[string]interface{} `json:"options,omitempty"`
}
// Learn - learning config for robot's private KB
// Private KB is auto-created: robot_{team_id}_{member_id}_kb
type Learn struct {
On bool `json:"on"`
Types []string `json:"types,omitempty"` // execution, feedback, insight
Keep int `json:"keep,omitempty"` // days, 0 = forever
}
// Resources - available agents and tools
type Resources struct {
Phases map[Phase]string `json:"phases,omitempty"` // phase -> agent ID
Agents []string `json:"agents,omitempty"`
MCP []MCPConfig `json:"mcp,omitempty"`
}
// GetPhaseAgent returns agent ID for phase (default: __yao.{phase})
func (r *Resources) GetPhaseAgent(phase Phase) string {
if r != nil && r.Phases != nil {
if id, ok := r.Phases[phase]; ok && id != "" {
return id
}
}
return "__yao." + string(phase)
}
// MCPConfig - MCP server configuration
type MCPConfig struct {
ID string `json:"id"`
Tools []string `json:"tools,omitempty"` // empty = all
}
// Delivery - output delivery
type Delivery struct {
Type DeliveryType `json:"type"`
Opts map[string]interface{} `json:"opts,omitempty"`
}
// Event - event trigger config
type Event struct {
Type EventSource `json:"type"` // webhook | database
Source string `json:"source"` // webhook path or table name
Filter map[string]interface{} `json:"filter,omitempty"`
}
// ParseConfig parses robot_config from various formats (string, []byte, map)
func ParseConfig(data interface{}) (*Config, error) {
if data == nil {
return nil, nil
}
var configBytes []byte
switch v := data.(type) {
case string:
if v == "" {
return nil, nil
}
configBytes = []byte(v)
case []byte:
if len(v) == 0 {
return nil, nil
}
configBytes = v
case map[string]interface{}:
var err error
configBytes, err = json.Marshal(v)
if err != nil {
return nil, err
}
default:
var err error
configBytes, err = json.Marshal(v)
if err != nil {
return nil, err
}
}
var config Config
if err := json.Unmarshal(configBytes, &config); err != nil {
return nil, err
}
return &config, nil
}

View file

@ -0,0 +1,252 @@
package types_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/types"
)
func TestConfigValidate(t *testing.T) {
t.Run("valid config", func(t *testing.T) {
config := &types.Config{
Identity: &types.Identity{
Role: "Sales Manager",
},
}
err := config.Validate()
assert.NoError(t, err)
})
t.Run("missing identity", func(t *testing.T) {
config := &types.Config{}
err := config.Validate()
assert.Error(t, err)
assert.Equal(t, types.ErrMissingIdentity, err)
})
t.Run("missing identity role", func(t *testing.T) {
config := &types.Config{
Identity: &types.Identity{},
}
err := config.Validate()
assert.Error(t, err)
assert.Equal(t, types.ErrMissingIdentity, err)
})
t.Run("invalid clock config", func(t *testing.T) {
config := &types.Config{
Identity: &types.Identity{Role: "Test"},
Clock: &types.Clock{
Mode: types.ClockTimes,
// Times is empty - should fail
},
}
err := config.Validate()
assert.Error(t, err)
assert.Equal(t, types.ErrClockTimesEmpty, err)
})
}
func TestClockValidate(t *testing.T) {
t.Run("valid times mode", func(t *testing.T) {
clock := &types.Clock{
Mode: types.ClockTimes,
Times: []string{"09:00", "14:00"},
}
err := clock.Validate()
assert.NoError(t, err)
})
t.Run("times mode without times", func(t *testing.T) {
clock := &types.Clock{
Mode: types.ClockTimes,
}
err := clock.Validate()
assert.Error(t, err)
assert.Equal(t, types.ErrClockTimesEmpty, err)
})
t.Run("valid interval mode", func(t *testing.T) {
clock := &types.Clock{
Mode: types.ClockInterval,
Every: "30m",
}
err := clock.Validate()
assert.NoError(t, err)
})
t.Run("interval mode without every", func(t *testing.T) {
clock := &types.Clock{
Mode: types.ClockInterval,
}
err := clock.Validate()
assert.Error(t, err)
assert.Equal(t, types.ErrClockIntervalEmpty, err)
})
t.Run("valid daemon mode", func(t *testing.T) {
clock := &types.Clock{
Mode: types.ClockDaemon,
}
err := clock.Validate()
assert.NoError(t, err)
})
t.Run("invalid mode", func(t *testing.T) {
clock := &types.Clock{
Mode: types.ClockMode("invalid"),
}
err := clock.Validate()
assert.Error(t, err)
assert.Equal(t, types.ErrClockModeInvalid, err)
})
}
func TestClockGetTimeout(t *testing.T) {
t.Run("default timeout", func(t *testing.T) {
clock := &types.Clock{}
timeout := clock.GetTimeout()
assert.Equal(t, 30*time.Minute, timeout)
})
t.Run("custom timeout", func(t *testing.T) {
clock := &types.Clock{
Timeout: "10m",
}
timeout := clock.GetTimeout()
assert.Equal(t, 10*time.Minute, timeout)
})
t.Run("invalid timeout returns default", func(t *testing.T) {
clock := &types.Clock{
Timeout: "invalid",
}
timeout := clock.GetTimeout()
assert.Equal(t, 30*time.Minute, timeout)
})
}
func TestClockGetLocation(t *testing.T) {
t.Run("default location", func(t *testing.T) {
clock := &types.Clock{}
loc := clock.GetLocation()
assert.Equal(t, time.Local, loc)
})
t.Run("valid timezone", func(t *testing.T) {
clock := &types.Clock{
TZ: "Asia/Shanghai",
}
loc := clock.GetLocation()
assert.NotNil(t, loc)
assert.Equal(t, "Asia/Shanghai", loc.String())
})
t.Run("invalid timezone returns local", func(t *testing.T) {
clock := &types.Clock{
TZ: "Invalid/Timezone",
}
loc := clock.GetLocation()
assert.Equal(t, time.Local, loc)
})
}
func TestTriggersIsEnabled(t *testing.T) {
t.Run("nil triggers - all enabled by default", func(t *testing.T) {
var triggers *types.Triggers
assert.True(t, triggers.IsEnabled(types.TriggerClock))
assert.True(t, triggers.IsEnabled(types.TriggerHuman))
assert.True(t, triggers.IsEnabled(types.TriggerEvent))
})
t.Run("clock enabled", func(t *testing.T) {
triggers := &types.Triggers{
Clock: &types.TriggerSwitch{Enabled: true},
}
assert.True(t, triggers.IsEnabled(types.TriggerClock))
})
t.Run("clock disabled", func(t *testing.T) {
triggers := &types.Triggers{
Clock: &types.TriggerSwitch{Enabled: false},
}
assert.False(t, triggers.IsEnabled(types.TriggerClock))
})
t.Run("intervene enabled by default", func(t *testing.T) {
triggers := &types.Triggers{}
assert.True(t, triggers.IsEnabled(types.TriggerHuman))
})
t.Run("event disabled", func(t *testing.T) {
triggers := &types.Triggers{
Event: &types.TriggerSwitch{Enabled: false},
}
assert.False(t, triggers.IsEnabled(types.TriggerEvent))
})
}
func TestQuotaDefaults(t *testing.T) {
t.Run("nil quota", func(t *testing.T) {
var quota *types.Quota
assert.Equal(t, 2, quota.GetMax())
assert.Equal(t, 10, quota.GetQueue())
assert.Equal(t, 5, quota.GetPriority())
})
t.Run("zero values", func(t *testing.T) {
quota := &types.Quota{}
assert.Equal(t, 2, quota.GetMax())
assert.Equal(t, 10, quota.GetQueue())
assert.Equal(t, 5, quota.GetPriority())
})
t.Run("custom values", func(t *testing.T) {
quota := &types.Quota{
Max: 5,
Queue: 20,
Priority: 8,
}
assert.Equal(t, 5, quota.GetMax())
assert.Equal(t, 20, quota.GetQueue())
assert.Equal(t, 8, quota.GetPriority())
})
}
func TestResourcesGetPhaseAgent(t *testing.T) {
t.Run("nil resources - returns default", func(t *testing.T) {
var resources *types.Resources
agent := resources.GetPhaseAgent(types.PhaseGoals)
assert.Equal(t, "__yao.goals", agent)
})
t.Run("phase not configured - returns default", func(t *testing.T) {
resources := &types.Resources{
Phases: map[types.Phase]string{},
}
agent := resources.GetPhaseAgent(types.PhaseGoals)
assert.Equal(t, "__yao.goals", agent)
})
t.Run("custom phase agent", func(t *testing.T) {
resources := &types.Resources{
Phases: map[types.Phase]string{
types.PhaseGoals: "custom.goals.agent",
},
}
agent := resources.GetPhaseAgent(types.PhaseGoals)
assert.Equal(t, "custom.goals.agent", agent)
})
t.Run("all phases default names", func(t *testing.T) {
resources := &types.Resources{}
assert.Equal(t, "__yao.inspiration", resources.GetPhaseAgent(types.PhaseInspiration))
assert.Equal(t, "__yao.goals", resources.GetPhaseAgent(types.PhaseGoals))
assert.Equal(t, "__yao.tasks", resources.GetPhaseAgent(types.PhaseTasks))
assert.Equal(t, "__yao.run", resources.GetPhaseAgent(types.PhaseRun))
assert.Equal(t, "__yao.delivery", resources.GetPhaseAgent(types.PhaseDelivery))
assert.Equal(t, "__yao.learning", resources.GetPhaseAgent(types.PhaseLearning))
})
}

View file

@ -0,0 +1,43 @@
package types
import (
"context"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// Context - robot execution context (lightweight)
type Context struct {
context.Context // embed standard context
Auth *types.AuthorizedInfo `json:"auth,omitempty"` // reuse oauth AuthorizedInfo
MemberID string `json:"member_id,omitempty"` // current robot member ID
RequestID string `json:"request_id,omitempty"` // request trace ID
Locale string `json:"locale,omitempty"` // locale (e.g., "en-US")
}
// NewContext creates a new robot context
func NewContext(parent context.Context, auth *types.AuthorizedInfo) *Context {
if parent == nil {
parent = context.Background()
}
return &Context{
Context: parent,
Auth: auth,
}
}
// UserID returns user ID from auth
func (c *Context) UserID() string {
if c.Auth == nil {
return ""
}
return c.Auth.UserID
}
// TeamID returns team ID from auth
func (c *Context) TeamID() string {
if c.Auth == nil {
return ""
}
return c.Auth.TeamID
}

191
agent/robot/types/enums.go Normal file
View file

@ -0,0 +1,191 @@
package types
// Phase - execution phase
type Phase string
// Phase constants define the execution phases for robot agent
const (
PhaseInspiration Phase = "inspiration" // P0: Clock only
PhaseGoals Phase = "goals" // P1
PhaseTasks Phase = "tasks" // P2
PhaseRun Phase = "run" // P3
PhaseDelivery Phase = "delivery" // P4
PhaseLearning Phase = "learning" // P5
)
// AllPhases for iteration
var AllPhases = []Phase{
PhaseInspiration, PhaseGoals, PhaseTasks,
PhaseRun, PhaseDelivery, PhaseLearning,
}
// ClockMode - clock trigger mode
type ClockMode string
// ClockMode constants define the clock trigger modes
const (
ClockTimes ClockMode = "times" // run at specific times
ClockInterval ClockMode = "interval" // run every X duration
ClockDaemon ClockMode = "daemon" // run continuously
)
// TriggerType - trigger source
type TriggerType string
// TriggerType constants define the trigger sources
const (
TriggerClock TriggerType = "clock"
TriggerHuman TriggerType = "human"
TriggerEvent TriggerType = "event"
)
// ExecStatus - execution status
type ExecStatus string
// ExecStatus constants define the execution status values
const (
ExecPending ExecStatus = "pending"
ExecRunning ExecStatus = "running"
ExecCompleted ExecStatus = "completed"
ExecFailed ExecStatus = "failed"
ExecCancelled ExecStatus = "cancelled"
)
// RobotStatus - matches __yao.member.robot_status
type RobotStatus string
// RobotStatus constants define the robot status values
const (
RobotIdle RobotStatus = "idle"
RobotWorking RobotStatus = "working"
RobotPaused RobotStatus = "paused"
RobotError RobotStatus = "error"
RobotMaintenance RobotStatus = "maintenance"
)
// InterventionAction - human intervention action
// Format: category.action (e.g., "task.add", "goal.adjust")
type InterventionAction string
// InterventionAction constants define the human intervention actions
const (
// ActionTaskAdd adds a new task
ActionTaskAdd InterventionAction = "task.add"
// ActionTaskCancel cancels a task
ActionTaskCancel InterventionAction = "task.cancel"
// ActionTaskUpdate updates task details
ActionTaskUpdate InterventionAction = "task.update"
// ActionGoalAdjust modifies current goal
ActionGoalAdjust InterventionAction = "goal.adjust"
// ActionGoalAdd adds a new goal
ActionGoalAdd InterventionAction = "goal.add"
// ActionGoalComplete marks goal as complete
ActionGoalComplete InterventionAction = "goal.complete"
// ActionGoalCancel cancels a goal
ActionGoalCancel InterventionAction = "goal.cancel"
// ActionPlanAdd adds to plan queue
ActionPlanAdd InterventionAction = "plan.add"
// ActionPlanRemove removes from plan queue
ActionPlanRemove InterventionAction = "plan.remove"
// ActionPlanUpdate updates planned item
ActionPlanUpdate InterventionAction = "plan.update"
// ActionInstruct is a direct instruction to robot
ActionInstruct InterventionAction = "instruct"
)
// Priority - task/goal priority
type Priority string
// Priority constants define the priority levels
const (
PriorityHigh Priority = "high"
PriorityNormal Priority = "normal"
PriorityLow Priority = "low"
)
// DeliveryType - output delivery type
type DeliveryType string
// DeliveryType constants define the output delivery types
const (
DeliveryEmail DeliveryType = "email"
DeliveryFile DeliveryType = "file"
DeliveryWebhook DeliveryType = "webhook"
DeliveryNotify DeliveryType = "notify"
)
// DedupResult - deduplication result
type DedupResult string
// DedupResult constants define the deduplication results
const (
DedupSkip DedupResult = "skip" // skip execution
DedupMerge DedupResult = "merge" // merge with existing
DedupProceed DedupResult = "proceed" // proceed normally
)
// EventSource - event trigger source
type EventSource string
// EventSource constants define the event trigger sources
const (
EventWebhook EventSource = "webhook" // HTTP webhook
EventDatabase EventSource = "database" // DB change trigger
)
// LearningType - learning entry type
type LearningType string
// LearningType constants define the learning entry types
const (
LearnExecution LearningType = "execution" // execution record
LearnFeedback LearningType = "feedback" // error/fix feedback
LearnInsight LearningType = "insight" // pattern/tip insight
)
// TaskSource - how task was created
type TaskSource string
// TaskSource constants define how a task was created
const (
TaskSourceAuto TaskSource = "auto" // generated by P2 (task planning)
TaskSourceHuman TaskSource = "human" // added via human intervention
TaskSourceEvent TaskSource = "event" // added via event trigger
)
// ExecutorType - task executor type
type ExecutorType string
// ExecutorType constants define the task executor types
const (
ExecutorAssistant ExecutorType = "assistant"
ExecutorMCP ExecutorType = "mcp"
ExecutorProcess ExecutorType = "process"
)
// TaskStatus - task execution status
type TaskStatus string
// TaskStatus constants define the task execution status values
const (
TaskPending TaskStatus = "pending"
TaskRunning TaskStatus = "running"
TaskCompleted TaskStatus = "completed"
TaskFailed TaskStatus = "failed"
TaskSkipped TaskStatus = "skipped"
TaskCancelled TaskStatus = "cancelled"
)
// InsertPosition - where to insert task in queue
type InsertPosition string
// InsertPosition constants define where to insert task in queue
const (
InsertFirst InsertPosition = "first" // insert at beginning (highest priority)
InsertLast InsertPosition = "last" // append at end (default)
InsertNext InsertPosition = "next" // insert after current task
InsertAt InsertPosition = "at" // insert at specific index (use AtIndex)
)

View file

@ -0,0 +1,134 @@
package types_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/types"
)
func TestPhaseEnum(t *testing.T) {
assert.Equal(t, types.Phase("inspiration"), types.PhaseInspiration)
assert.Equal(t, types.Phase("goals"), types.PhaseGoals)
assert.Equal(t, types.Phase("tasks"), types.PhaseTasks)
assert.Equal(t, types.Phase("run"), types.PhaseRun)
assert.Equal(t, types.Phase("delivery"), types.PhaseDelivery)
assert.Equal(t, types.Phase("learning"), types.PhaseLearning)
}
func TestAllPhases(t *testing.T) {
assert.Len(t, types.AllPhases, 6)
assert.Equal(t, types.PhaseInspiration, types.AllPhases[0])
assert.Equal(t, types.PhaseGoals, types.AllPhases[1])
assert.Equal(t, types.PhaseTasks, types.AllPhases[2])
assert.Equal(t, types.PhaseRun, types.AllPhases[3])
assert.Equal(t, types.PhaseDelivery, types.AllPhases[4])
assert.Equal(t, types.PhaseLearning, types.AllPhases[5])
}
func TestClockModeEnum(t *testing.T) {
assert.Equal(t, types.ClockMode("times"), types.ClockTimes)
assert.Equal(t, types.ClockMode("interval"), types.ClockInterval)
assert.Equal(t, types.ClockMode("daemon"), types.ClockDaemon)
}
func TestTriggerTypeEnum(t *testing.T) {
assert.Equal(t, types.TriggerType("clock"), types.TriggerClock)
assert.Equal(t, types.TriggerType("human"), types.TriggerHuman)
assert.Equal(t, types.TriggerType("event"), types.TriggerEvent)
}
func TestExecStatusEnum(t *testing.T) {
assert.Equal(t, types.ExecStatus("pending"), types.ExecPending)
assert.Equal(t, types.ExecStatus("running"), types.ExecRunning)
assert.Equal(t, types.ExecStatus("completed"), types.ExecCompleted)
assert.Equal(t, types.ExecStatus("failed"), types.ExecFailed)
assert.Equal(t, types.ExecStatus("cancelled"), types.ExecCancelled)
}
func TestRobotStatusEnum(t *testing.T) {
assert.Equal(t, types.RobotStatus("idle"), types.RobotIdle)
assert.Equal(t, types.RobotStatus("working"), types.RobotWorking)
assert.Equal(t, types.RobotStatus("paused"), types.RobotPaused)
assert.Equal(t, types.RobotStatus("error"), types.RobotError)
assert.Equal(t, types.RobotStatus("maintenance"), types.RobotMaintenance)
}
func TestInterventionActionEnum(t *testing.T) {
// Task operations
assert.Equal(t, types.InterventionAction("task.add"), types.ActionTaskAdd)
assert.Equal(t, types.InterventionAction("task.cancel"), types.ActionTaskCancel)
assert.Equal(t, types.InterventionAction("task.update"), types.ActionTaskUpdate)
// Goal operations
assert.Equal(t, types.InterventionAction("goal.adjust"), types.ActionGoalAdjust)
assert.Equal(t, types.InterventionAction("goal.add"), types.ActionGoalAdd)
assert.Equal(t, types.InterventionAction("goal.complete"), types.ActionGoalComplete)
assert.Equal(t, types.InterventionAction("goal.cancel"), types.ActionGoalCancel)
// Plan operations
assert.Equal(t, types.InterventionAction("plan.add"), types.ActionPlanAdd)
assert.Equal(t, types.InterventionAction("plan.remove"), types.ActionPlanRemove)
assert.Equal(t, types.InterventionAction("plan.update"), types.ActionPlanUpdate)
// Instruction
assert.Equal(t, types.InterventionAction("instruct"), types.ActionInstruct)
}
func TestPriorityEnum(t *testing.T) {
assert.Equal(t, types.Priority("high"), types.PriorityHigh)
assert.Equal(t, types.Priority("normal"), types.PriorityNormal)
assert.Equal(t, types.Priority("low"), types.PriorityLow)
}
func TestDeliveryTypeEnum(t *testing.T) {
assert.Equal(t, types.DeliveryType("email"), types.DeliveryEmail)
assert.Equal(t, types.DeliveryType("file"), types.DeliveryFile)
assert.Equal(t, types.DeliveryType("webhook"), types.DeliveryWebhook)
assert.Equal(t, types.DeliveryType("notify"), types.DeliveryNotify)
}
func TestDedupResultEnum(t *testing.T) {
assert.Equal(t, types.DedupResult("skip"), types.DedupSkip)
assert.Equal(t, types.DedupResult("merge"), types.DedupMerge)
assert.Equal(t, types.DedupResult("proceed"), types.DedupProceed)
}
func TestEventSourceEnum(t *testing.T) {
assert.Equal(t, types.EventSource("webhook"), types.EventWebhook)
assert.Equal(t, types.EventSource("database"), types.EventDatabase)
}
func TestLearningTypeEnum(t *testing.T) {
assert.Equal(t, types.LearningType("execution"), types.LearnExecution)
assert.Equal(t, types.LearningType("feedback"), types.LearnFeedback)
assert.Equal(t, types.LearningType("insight"), types.LearnInsight)
}
func TestTaskSourceEnum(t *testing.T) {
assert.Equal(t, types.TaskSource("auto"), types.TaskSourceAuto)
assert.Equal(t, types.TaskSource("human"), types.TaskSourceHuman)
assert.Equal(t, types.TaskSource("event"), types.TaskSourceEvent)
}
func TestExecutorTypeEnum(t *testing.T) {
assert.Equal(t, types.ExecutorType("assistant"), types.ExecutorAssistant)
assert.Equal(t, types.ExecutorType("mcp"), types.ExecutorMCP)
assert.Equal(t, types.ExecutorType("process"), types.ExecutorProcess)
}
func TestTaskStatusEnum(t *testing.T) {
assert.Equal(t, types.TaskStatus("pending"), types.TaskPending)
assert.Equal(t, types.TaskStatus("running"), types.TaskRunning)
assert.Equal(t, types.TaskStatus("completed"), types.TaskCompleted)
assert.Equal(t, types.TaskStatus("failed"), types.TaskFailed)
assert.Equal(t, types.TaskStatus("skipped"), types.TaskSkipped)
assert.Equal(t, types.TaskStatus("cancelled"), types.TaskCancelled)
}
func TestInsertPositionEnum(t *testing.T) {
assert.Equal(t, types.InsertPosition("first"), types.InsertFirst)
assert.Equal(t, types.InsertPosition("last"), types.InsertLast)
assert.Equal(t, types.InsertPosition("next"), types.InsertNext)
assert.Equal(t, types.InsertPosition("at"), types.InsertAt)
}

View file

@ -0,0 +1,48 @@
package types
import "errors"
// ErrMissingIdentity indicates identity.role is required
var ErrMissingIdentity = errors.New("identity.role is required")
// ErrClockTimesEmpty indicates clock.times is required for times mode
var ErrClockTimesEmpty = errors.New("clock.times is required for times mode")
// ErrClockIntervalEmpty indicates clock.every is required for interval mode
var ErrClockIntervalEmpty = errors.New("clock.every is required for interval mode")
// ErrClockModeInvalid indicates clock.mode must be times, interval, or daemon
var ErrClockModeInvalid = errors.New("clock.mode must be times, interval, or daemon")
// ErrRobotNotFound indicates robot not found
var ErrRobotNotFound = errors.New("robot not found")
// ErrRobotPaused indicates robot is paused
var ErrRobotPaused = errors.New("robot is paused")
// ErrRobotBusy indicates robot has reached max concurrent executions
var ErrRobotBusy = errors.New("robot has reached max concurrent executions")
// ErrQuotaExceeded indicates robot quota was exceeded (atomic check failed)
var ErrQuotaExceeded = errors.New("robot quota exceeded")
// ErrTriggerDisabled indicates trigger type is disabled for this robot
var ErrTriggerDisabled = errors.New("trigger type is disabled for this robot")
// ErrExecutionCancelled indicates execution was cancelled
var ErrExecutionCancelled = errors.New("execution was cancelled")
// ErrExecutionTimeout indicates execution timed out
var ErrExecutionTimeout = errors.New("execution timed out")
// ErrPhaseAgentNotFound indicates phase agent not found
var ErrPhaseAgentNotFound = errors.New("phase agent not found")
// ErrGoalGenFailed indicates goal generation failed
var ErrGoalGenFailed = errors.New("goal generation failed")
// ErrTaskPlanFailed indicates task planning failed
var ErrTaskPlanFailed = errors.New("task planning failed")
// ErrDeliveryFailed indicates delivery failed
var ErrDeliveryFailed = errors.New("delivery failed")

View file

@ -0,0 +1,22 @@
package types
// InspirationReport - P0 output (simple markdown for LLM)
type InspirationReport struct {
Clock *ClockContext `json:"clock"` // time context
Content string `json:"content"` // markdown text for LLM
}
// Content is markdown like:
// ## Summary
// ...
// ## Highlights
// - [High] Sales up 50%
// - [Medium] New lead from BigCorp
// ## Opportunities
// ...
// ## Risks
// ...
// ## World News
// ...
// ## Pending
// ...

View file

@ -0,0 +1,53 @@
package types
import "time"
// ==================== Internal Interfaces ====================
// These are internal implementation interfaces, not exposed via API.
// External API is defined in api/api.go
// All interfaces use *Context (not context.Context) for consistency.
// Manager - robot lifecycle and clock trigger management
type Manager interface {
Start() error
Stop() error
Tick(ctx *Context, now time.Time) error
}
// Executor - executes robot phases
type Executor interface {
Execute(ctx *Context, robot *Robot, trigger TriggerType, data interface{}) (*Execution, error)
}
// Pool - worker pool for concurrent execution
type Pool interface {
Start() error
Stop() error
Submit(ctx *Context, robot *Robot, trigger TriggerType, data interface{}) (string, error)
Running() int
Queued() int
}
// Cache - in-memory robot cache
type Cache interface {
Load(ctx *Context) error
Get(memberID string) *Robot
List(teamID string) []*Robot
Refresh(ctx *Context, memberID string) error
Add(robot *Robot)
Remove(memberID string)
}
// Dedup - deduplication check
type Dedup interface {
Check(ctx *Context, memberID string, trigger TriggerType) (DedupResult, error)
Mark(memberID string, trigger TriggerType, window time.Duration)
}
// Store - data storage operations (KB, DB)
type Store interface {
SaveLearning(ctx *Context, memberID string, entries []LearningEntry) error
GetHistory(ctx *Context, memberID string, limit int) ([]LearningEntry, error)
SearchKB(ctx *Context, collections []string, query string) ([]interface{}, error)
QueryDB(ctx *Context, models []string, query interface{}) ([]interface{}, error)
}

View file

@ -0,0 +1,44 @@
package types
import (
"time"
agentcontext "github.com/yaoapp/yao/agent/context"
)
// 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
}
// 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"`
}
// ExecutionResult - trigger result
type ExecutionResult struct {
ExecutionID string `json:"execution_id"`
Status ExecStatus `json:"status"`
Message string `json:"message,omitempty"`
}
// RobotState - robot status query result
type RobotState struct {
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
DisplayName string `json:"display_name"`
Status RobotStatus `json:"status"`
Running int `json:"running"` // current running execution count
MaxRunning int `json:"max_running"` // max concurrent allowed
LastRun *time.Time `json:"last_run,omitempty"`
NextRun *time.Time `json:"next_run,omitempty"`
RunningIDs []string `json:"running_ids,omitempty"` // list of running execution IDs
}

307
agent/robot/types/robot.go Normal file
View file

@ -0,0 +1,307 @@
package types
import (
"context"
"fmt"
"sync"
"time"
agentcontext "github.com/yaoapp/yao/agent/context"
)
// Robot - runtime representation of an autonomous robot (from __yao.member)
// Relationship: 1 Robot : N Executions (concurrent)
// Each trigger creates a new Execution (mapped to job.Job)
type Robot struct {
// From __yao.member
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
DisplayName string `json:"display_name"`
SystemPrompt string `json:"system_prompt"`
Status RobotStatus `json:"robot_status"`
AutonomousMode bool `json:"autonomous_mode"`
// Parsed config (from robot_config JSON field)
Config *Config `json:"-"`
// Runtime state
LastRun time.Time `json:"-"` // last execution start time
NextRun time.Time `json:"-"` // next scheduled execution (for clock trigger)
// Concurrency control
// Each Robot can run multiple Executions concurrently (up to Quota.Max)
executions map[string]*Execution // execID -> Execution
execMu sync.RWMutex
}
// CanRun checks if robot can accept new execution
// Note: This is a read-only check. For atomic check-and-acquire, use TryAcquireSlot()
func (r *Robot) CanRun() bool {
r.execMu.RLock()
defer r.execMu.RUnlock()
if r.Config == nil {
return len(r.executions) < 2 // default max
}
return len(r.executions) < r.Config.Quota.GetMax()
}
// TryAcquireSlot atomically checks if robot can run and reserves a slot
// Returns true if slot was acquired, false if quota is full
// This prevents race conditions between CanRun() check and AddExecution()
func (r *Robot) TryAcquireSlot(exec *Execution) bool {
r.execMu.Lock()
defer r.execMu.Unlock()
// Get max quota
maxQuota := 2 // default
if r.Config != nil {
maxQuota = r.Config.Quota.GetMax()
}
// Check if we can add
if len(r.executions) >= maxQuota {
return false // quota full
}
// Reserve slot by adding execution
if r.executions == nil {
r.executions = make(map[string]*Execution)
}
r.executions[exec.ID] = exec
return true
}
// RunningCount returns current running execution count
func (r *Robot) RunningCount() int {
r.execMu.RLock()
defer r.execMu.RUnlock()
return len(r.executions)
}
// AddExecution adds an execution to tracking
// Note: Prefer TryAcquireSlot() for atomic check-and-add
func (r *Robot) AddExecution(exec *Execution) {
r.execMu.Lock()
defer r.execMu.Unlock()
if r.executions == nil {
r.executions = make(map[string]*Execution)
}
r.executions[exec.ID] = exec
}
// RemoveExecution removes an execution from tracking
func (r *Robot) RemoveExecution(execID string) {
r.execMu.Lock()
defer r.execMu.Unlock()
delete(r.executions, execID)
}
// GetExecution returns an execution by ID
func (r *Robot) GetExecution(execID string) *Execution {
r.execMu.RLock()
defer r.execMu.RUnlock()
return r.executions[execID]
}
// GetExecutions returns all running executions
func (r *Robot) GetExecutions() []*Execution {
r.execMu.RLock()
defer r.execMu.RUnlock()
execs := make([]*Execution, 0, len(r.executions))
for _, exec := range r.executions {
execs = append(execs, exec)
}
return execs
}
// Execution - single execution instance
// Each trigger creates a new Execution, mapped to a job.Job for monitoring
// Relationship: 1 Execution = 1 job.Job
type Execution struct {
ID string `json:"id"` // unique execution ID
MemberID string `json:"member_id"` // robot member ID
TeamID string `json:"team_id"`
TriggerType TriggerType `json:"trigger_type"` // clock | human | event
StartTime time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time,omitempty"`
Status ExecStatus `json:"status"`
Phase Phase `json:"phase"`
Error string `json:"error,omitempty"`
// Job integration (each Execution = 1 job.Job)
JobID string `json:"job_id"` // corresponding job.Job ID
// Trigger input (stored for traceability)
Input *TriggerInput `json:"input,omitempty"` // original trigger input
// Phase outputs
Inspiration *InspirationReport `json:"inspiration,omitempty"` // P0: markdown
Goals *Goals `json:"goals,omitempty"` // P1: markdown
Tasks []Task `json:"tasks,omitempty"` // P2: structured tasks
Current *CurrentState `json:"current,omitempty"` // current executing state
Results []TaskResult `json:"results,omitempty"` // P3: task results
Delivery *DeliveryResult `json:"delivery,omitempty"`
Learning []LearningEntry `json:"learning,omitempty"`
// Runtime (internal, not serialized)
ctx context.Context `json:"-"`
cancel context.CancelFunc `json:"-"`
robot *Robot `json:"-"`
}
// TriggerInput - stored trigger input for traceability
type TriggerInput struct {
// For human intervention
Action InterventionAction `json:"action,omitempty"` // task.add, goal.adjust, etc.
Messages []agentcontext.Message `json:"messages,omitempty"` // user's input (text, images, files)
UserID string `json:"user_id,omitempty"` // who triggered
// For event trigger
Source EventSource `json:"source,omitempty"` // webhook | database
EventType string `json:"event_type,omitempty"` // lead.created, etc.
Data map[string]interface{} `json:"data,omitempty"` // event payload
// For clock trigger
Clock *ClockContext `json:"clock,omitempty"` // time context when triggered
}
// CurrentState - current executing goal and task
type CurrentState struct {
Task *Task `json:"task,omitempty"` // current task being executed
TaskIndex int `json:"task_index"` // index in Tasks slice
Progress string `json:"progress,omitempty"` // human-readable progress (e.g., "2/5 tasks")
}
// Goals - P1 output (markdown for LLM)
// P1 Agent reads InspirationReport and generates goals as markdown
// Example:
// ## Goals
// 1. [High] Analyze sales data and identify trends
// - Reason: Sales up 50%, need to understand why
//
// 2. [Normal] Prepare weekly report for manager
// - Reason: Friday 5pm, weekly report due
//
// 3. [Low] Update CRM with new leads
// - Reason: 3 pending leads from yesterday
type Goals struct {
Content string `json:"content"` // markdown text
}
// Task - planned task (structured, for execution)
type Task struct {
ID string `json:"id"`
Messages []agentcontext.Message `json:"messages"` // original input (text, images, files)
GoalRef string `json:"goal_ref,omitempty"` // reference to goal (e.g., "Goal 1")
Source TaskSource `json:"source"` // auto | human | event
// Executor
ExecutorType ExecutorType `json:"executor_type"`
ExecutorID string `json:"executor_id"`
Args []any `json:"args,omitempty"`
// Runtime
Status TaskStatus `json:"status"`
Order int `json:"order"` // execution order (0-based)
StartTime *time.Time `json:"start_time,omitempty"`
EndTime *time.Time `json:"end_time,omitempty"`
}
// 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"`
}
// DeliveryResult - delivery output
type DeliveryResult struct {
Type DeliveryType `json:"type"`
Success bool `json:"success"`
Details interface{} `json:"details,omitempty"`
Error string `json:"error,omitempty"`
}
// LearningEntry - knowledge to save
type LearningEntry struct {
Type LearningType `json:"type"` // execution | feedback | insight
Content string `json:"content"`
Tags []string `json:"tags,omitempty"`
Meta interface{} `json:"meta,omitempty"`
}
// NewRobotFromMap creates a Robot from a map (typically from DB record)
func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
memberID := getString(m, "member_id")
teamID := getString(m, "team_id")
// Validate required fields
if memberID == "" || teamID == "" {
return nil, fmt.Errorf("missing required fields: member_id or team_id")
}
robot := &Robot{
MemberID: memberID,
TeamID: teamID,
DisplayName: getString(m, "display_name"),
SystemPrompt: getString(m, "system_prompt"),
AutonomousMode: getBool(m, "autonomous_mode"),
}
// Parse robot_status
if status := getString(m, "robot_status"); status != "" {
robot.Status = RobotStatus(status)
} else {
robot.Status = RobotIdle
}
// Parse robot_config JSON
if configData, ok := m["robot_config"]; ok && configData != nil {
config, err := ParseConfig(configData)
if err != nil {
return nil, fmt.Errorf("failed to parse robot_config: %w", err)
}
robot.Config = config
}
return robot, nil
}
// getString safely gets a string value from map
func getString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if v, ok := m[key]; ok && v != nil {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
return ""
}
// getBool safely gets a bool value from map
func getBool(m map[string]interface{}, key string) bool {
if m == nil {
return false
}
if v, ok := m[key]; ok && v != nil {
switch b := v.(type) {
case bool:
return b
case int:
return b != 0
case int64:
return b != 0
case float64:
return b != 0
case string:
return b == "true" || b == "1"
}
}
return false
}

View file

@ -0,0 +1,466 @@
package types_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/types"
)
func TestRobotCanRun(t *testing.T) {
t.Run("can run when under quota", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 2},
},
}
assert.True(t, robot.CanRun())
})
t.Run("can run with nil config (uses default quota)", func(t *testing.T) {
robot := &types.Robot{
Config: nil, // nil config should not panic
}
// Should not panic and use default max (2)
assert.True(t, robot.CanRun())
})
t.Run("can run with nil quota (uses default)", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: nil, // nil quota should use default
},
}
assert.True(t, robot.CanRun())
})
t.Run("cannot run when at quota", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 2},
},
}
// Add 2 executions to reach quota
exec1 := &types.Execution{ID: "exec1"}
exec2 := &types.Execution{ID: "exec2"}
robot.AddExecution(exec1)
robot.AddExecution(exec2)
assert.False(t, robot.CanRun())
})
t.Run("can run after removing execution", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 2},
},
}
exec1 := &types.Execution{ID: "exec1"}
exec2 := &types.Execution{ID: "exec2"}
robot.AddExecution(exec1)
robot.AddExecution(exec2)
assert.False(t, robot.CanRun())
robot.RemoveExecution("exec1")
assert.True(t, robot.CanRun())
})
}
func TestRobotRunningCount(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 5},
},
}
assert.Equal(t, 0, robot.RunningCount())
exec1 := &types.Execution{ID: "exec1"}
robot.AddExecution(exec1)
assert.Equal(t, 1, robot.RunningCount())
exec2 := &types.Execution{ID: "exec2"}
robot.AddExecution(exec2)
assert.Equal(t, 2, robot.RunningCount())
robot.RemoveExecution("exec1")
assert.Equal(t, 1, robot.RunningCount())
robot.RemoveExecution("exec2")
assert.Equal(t, 0, robot.RunningCount())
}
func TestRobotAddExecution(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 2},
},
}
exec := &types.Execution{
ID: "exec1",
MemberID: "member1",
}
robot.AddExecution(exec)
assert.Equal(t, 1, robot.RunningCount())
retrieved := robot.GetExecution("exec1")
assert.NotNil(t, retrieved)
assert.Equal(t, "exec1", retrieved.ID)
assert.Equal(t, "member1", retrieved.MemberID)
}
func TestRobotRemoveExecution(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 2},
},
}
exec := &types.Execution{ID: "exec1"}
robot.AddExecution(exec)
assert.Equal(t, 1, robot.RunningCount())
robot.RemoveExecution("exec1")
assert.Equal(t, 0, robot.RunningCount())
retrieved := robot.GetExecution("exec1")
assert.Nil(t, retrieved)
}
func TestRobotGetExecution(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 2},
},
}
t.Run("get existing execution", func(t *testing.T) {
exec := &types.Execution{
ID: "exec1",
MemberID: "member1",
}
robot.AddExecution(exec)
retrieved := robot.GetExecution("exec1")
assert.NotNil(t, retrieved)
assert.Equal(t, "exec1", retrieved.ID)
})
t.Run("get non-existing execution", func(t *testing.T) {
retrieved := robot.GetExecution("non-existing")
assert.Nil(t, retrieved)
})
}
func TestRobotGetExecutions(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 5},
},
}
t.Run("empty executions", func(t *testing.T) {
execs := robot.GetExecutions()
assert.Empty(t, execs)
})
t.Run("multiple executions", func(t *testing.T) {
exec1 := &types.Execution{ID: "exec1"}
exec2 := &types.Execution{ID: "exec2"}
exec3 := &types.Execution{ID: "exec3"}
robot.AddExecution(exec1)
robot.AddExecution(exec2)
robot.AddExecution(exec3)
execs := robot.GetExecutions()
assert.Len(t, execs, 3)
// Check all executions are present
ids := make(map[string]bool)
for _, exec := range execs {
ids[exec.ID] = true
}
assert.True(t, ids["exec1"])
assert.True(t, ids["exec2"])
assert.True(t, ids["exec3"])
})
}
func TestRobotConcurrentAccess(t *testing.T) {
// Test thread-safe execution management
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 10},
},
}
// Add executions concurrently
done := make(chan bool)
for i := 0; i < 5; i++ {
go func(id int) {
exec := &types.Execution{ID: string(rune('0' + id))}
robot.AddExecution(exec)
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < 5; i++ {
<-done
}
// Verify count
count := robot.RunningCount()
assert.Equal(t, 5, count)
// Remove executions concurrently
for i := 0; i < 5; i++ {
go func(id int) {
robot.RemoveExecution(string(rune('0' + id)))
done <- true
}(i)
}
// Wait for all goroutines
for i := 0; i < 5; i++ {
<-done
}
// Verify count
count = robot.RunningCount()
assert.Equal(t, 0, count)
}
func TestRobotTryAcquireSlot(t *testing.T) {
t.Run("acquire slot when under quota", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 2},
},
}
exec := &types.Execution{ID: "exec1"}
acquired := robot.TryAcquireSlot(exec)
assert.True(t, acquired)
assert.Equal(t, 1, robot.RunningCount())
assert.NotNil(t, robot.GetExecution("exec1"))
})
t.Run("fail to acquire when at quota", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 2},
},
}
// Fill quota
robot.TryAcquireSlot(&types.Execution{ID: "exec1"})
robot.TryAcquireSlot(&types.Execution{ID: "exec2"})
// Try to acquire one more
exec3 := &types.Execution{ID: "exec3"}
acquired := robot.TryAcquireSlot(exec3)
assert.False(t, acquired)
assert.Equal(t, 2, robot.RunningCount())
assert.Nil(t, robot.GetExecution("exec3"))
})
t.Run("acquire with nil config uses default quota", func(t *testing.T) {
robot := &types.Robot{
Config: nil, // default quota is 2
}
exec1 := &types.Execution{ID: "exec1"}
exec2 := &types.Execution{ID: "exec2"}
exec3 := &types.Execution{ID: "exec3"}
assert.True(t, robot.TryAcquireSlot(exec1))
assert.True(t, robot.TryAcquireSlot(exec2))
assert.False(t, robot.TryAcquireSlot(exec3)) // should fail at default max=2
})
}
func TestRobotTryAcquireSlotConcurrent(t *testing.T) {
// Test that TryAcquireSlot is atomic and prevents exceeding quota
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 5},
},
}
// Launch 20 goroutines trying to acquire slots
successCount := make(chan bool, 20)
for i := 0; i < 20; i++ {
go func(id int) {
exec := &types.Execution{ID: string(rune('A' + id))}
success := robot.TryAcquireSlot(exec)
successCount <- success
}(i)
}
// Count successes
acquired := 0
for i := 0; i < 20; i++ {
if <-successCount {
acquired++
}
}
// Should have exactly 5 successful acquisitions (quota max)
assert.Equal(t, 5, acquired, "Should acquire exactly quota max slots")
assert.Equal(t, 5, robot.RunningCount(), "Running count should match quota max")
}
func TestRobotTryAcquireSlotRaceCondition(t *testing.T) {
// Stress test to verify no race condition in TryAcquireSlot
for iteration := 0; iteration < 100; iteration++ {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 3},
},
}
// Launch many goroutines simultaneously
successCount := make(chan bool, 50)
for i := 0; i < 50; i++ {
go func(id int) {
exec := &types.Execution{ID: string(rune('A'+id%26)) + string(rune('0'+id/26))}
success := robot.TryAcquireSlot(exec)
successCount <- success
}(i)
}
// Count successes
acquired := 0
for i := 0; i < 50; i++ {
if <-successCount {
acquired++
}
}
// Should never exceed quota
assert.Equal(t, 3, acquired, "Iteration %d: Should acquire exactly quota max slots", iteration)
assert.Equal(t, 3, robot.RunningCount(), "Iteration %d: Running count should match quota max", iteration)
}
}
func TestExecutionStructure(t *testing.T) {
t.Run("execution with all fields", func(t *testing.T) {
exec := &types.Execution{
ID: "exec1",
MemberID: "member1",
TeamID: "team1",
TriggerType: types.TriggerClock,
Status: types.ExecRunning,
Phase: types.PhaseGoals,
JobID: "job1",
}
assert.Equal(t, "exec1", exec.ID)
assert.Equal(t, "member1", exec.MemberID)
assert.Equal(t, "team1", exec.TeamID)
assert.Equal(t, types.TriggerClock, exec.TriggerType)
assert.Equal(t, types.ExecRunning, exec.Status)
assert.Equal(t, types.PhaseGoals, exec.Phase)
assert.Equal(t, "job1", exec.JobID)
})
t.Run("execution with trigger input", func(t *testing.T) {
exec := &types.Execution{
ID: "exec1",
Input: &types.TriggerInput{
Action: types.ActionTaskAdd,
UserID: "user1",
},
}
assert.NotNil(t, exec.Input)
assert.Equal(t, types.ActionTaskAdd, exec.Input.Action)
assert.Equal(t, "user1", exec.Input.UserID)
})
}
func TestTaskStructure(t *testing.T) {
task := &types.Task{
ID: "task1",
GoalRef: "Goal 1",
Source: types.TaskSourceAuto,
ExecutorType: types.ExecutorAssistant,
ExecutorID: "assistant1",
Status: types.TaskPending,
Order: 0,
}
assert.Equal(t, "task1", task.ID)
assert.Equal(t, "Goal 1", task.GoalRef)
assert.Equal(t, types.TaskSourceAuto, task.Source)
assert.Equal(t, types.ExecutorAssistant, task.ExecutorType)
assert.Equal(t, "assistant1", task.ExecutorID)
assert.Equal(t, types.TaskPending, task.Status)
assert.Equal(t, 0, task.Order)
}
func TestGoalsStructure(t *testing.T) {
goals := &types.Goals{
Content: "## Goals\n1. [High] Complete project\n2. [Normal] Review code",
}
assert.Contains(t, goals.Content, "Goals")
assert.Contains(t, goals.Content, "Complete project")
}
func TestTaskResultStructure(t *testing.T) {
result := &types.TaskResult{
TaskID: "task1",
Success: true,
Output: "Task completed successfully",
Duration: 1500,
Validated: true,
}
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)
}
func TestDeliveryResultStructure(t *testing.T) {
delivery := &types.DeliveryResult{
Type: types.DeliveryEmail,
Success: true,
Details: map[string]interface{}{
"to": "user@example.com",
"subject": "Daily Report",
},
}
assert.Equal(t, types.DeliveryEmail, delivery.Type)
assert.True(t, delivery.Success)
assert.NotNil(t, delivery.Details)
}
func TestLearningEntryStructure(t *testing.T) {
entry := &types.LearningEntry{
Type: types.LearnExecution,
Content: "Successfully completed task using assistant",
Tags: []string{"success", "assistant"},
Meta: map[string]interface{}{
"duration": 1500,
"phase": "run",
},
}
assert.Equal(t, types.LearnExecution, entry.Type)
assert.Equal(t, "Successfully completed task using assistant", entry.Content)
assert.Len(t, entry.Tags, 2)
assert.NotNil(t, entry.Meta)
}

View file

@ -0,0 +1,146 @@
package utils
import (
"encoding/json"
"fmt"
)
// ToJSON converts any value to JSON string
func ToJSON(v interface{}) (string, error) {
data, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(data), nil
}
// FromJSON parses JSON string to target
func FromJSON(jsonStr string, target interface{}) error {
return json.Unmarshal([]byte(jsonStr), target)
}
// ToMap converts struct to map[string]interface{}
func ToMap(v interface{}) (map[string]interface{}, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
// FromMap converts map to struct
func FromMap(m map[string]interface{}, target interface{}) error {
data, err := json.Marshal(m)
if err != nil {
return err
}
return json.Unmarshal(data, target)
}
// ToString converts any value to string
func ToString(v interface{}) string {
switch val := v.(type) {
case string:
return val
case []byte:
return string(val)
case int, int8, int16, int32, int64:
return fmt.Sprintf("%d", val)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", val)
case float32, float64:
return fmt.Sprintf("%f", val)
case bool:
return fmt.Sprintf("%t", val)
default:
// Fallback to JSON
if str, err := ToJSON(v); err == nil {
return str
}
return fmt.Sprintf("%v", v)
}
}
// MergeMap merges source map into target map (shallow copy)
func MergeMap(target, source map[string]interface{}) map[string]interface{} {
if target == nil {
target = make(map[string]interface{})
}
for k, v := range source {
target[k] = v
}
return target
}
// CloneMap creates a shallow copy of a map
func CloneMap(m map[string]interface{}) map[string]interface{} {
if m == nil {
return nil
}
result := make(map[string]interface{}, len(m))
for k, v := range m {
result[k] = v
}
return result
}
// GetString safely gets a string value from map
func GetString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
if v, ok := m[key]; ok && v != nil {
return ToString(v)
}
return ""
}
// GetBool safely gets a bool value from map
func GetBool(m map[string]interface{}, key string) bool {
if m == nil {
return false
}
if v, ok := m[key]; ok && v != nil {
switch b := v.(type) {
case bool:
return b
case int:
return b != 0
case int64:
return b != 0
case float64:
return b != 0
case string:
return b == "true" || b == "1"
}
}
return false
}
// GetInt safely gets an int value from map
func GetInt(m map[string]interface{}, key string) int {
if m == nil {
return 0
}
if v, ok := m[key]; ok && v != nil {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case string:
var i int
fmt.Sscanf(n, "%d", &i)
return i
}
}
return 0
}

20
agent/robot/utils/id.go Normal file
View file

@ -0,0 +1,20 @@
package utils
import (
gonanoid "github.com/matoous/go-nanoid/v2"
)
// NewID generates a new unique ID using nanoid
func NewID() string {
id, err := gonanoid.New()
if err != nil {
// Fallback to nanoid with default alphabet if error occurs
return gonanoid.Must()
}
return id
}
// NewIDWithPrefix generates a new ID with a prefix
func NewIDWithPrefix(prefix string) string {
return prefix + NewID()
}

114
agent/robot/utils/time.go Normal file
View file

@ -0,0 +1,114 @@
package utils
import (
"fmt"
"strconv"
"strings"
"time"
)
// ParseTime parses a time string in HH:MM format
func ParseTime(timeStr string) (hour, minute int, err error) {
parts := strings.Split(timeStr, ":")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid time format: %s (expected HH:MM)", timeStr)
}
hour, err = strconv.Atoi(parts[0])
if err != nil || hour < 0 || hour > 23 {
return 0, 0, fmt.Errorf("invalid hour: %s", parts[0])
}
minute, err = strconv.Atoi(parts[1])
if err != nil || minute < 0 || minute > 59 {
return 0, 0, fmt.Errorf("invalid minute: %s", parts[1])
}
return hour, minute, nil
}
// FormatTime formats hour and minute into HH:MM format
func FormatTime(hour, minute int) string {
return fmt.Sprintf("%02d:%02d", hour, minute)
}
// LoadLocation loads a timezone location, returns Local if empty or invalid
func LoadLocation(tz string) *time.Location {
if tz == "" {
return time.Local
}
loc, err := time.LoadLocation(tz)
if err != nil {
return time.Local
}
return loc
}
// ParseDuration parses a duration string with fallback default
func ParseDuration(durStr string, defaultDur time.Duration) time.Duration {
if durStr == "" {
return defaultDur
}
d, err := time.ParseDuration(durStr)
if err != nil {
return defaultDur
}
return d
}
// IsTimeMatch checks if current time matches the specified time (HH:MM)
func IsTimeMatch(now time.Time, timeStr string, loc *time.Location) bool {
hour, minute, err := ParseTime(timeStr)
if err != nil {
return false
}
nowInLoc := now.In(loc)
return nowInLoc.Hour() == hour && nowInLoc.Minute() == minute
}
// IsDayMatch checks if current day matches the specified day
// days can be: "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", or "*" for any day
func IsDayMatch(now time.Time, days []string) bool {
if len(days) == 0 {
return true
}
dayName := now.Weekday().String()[:3] // "Monday" -> "Mon"
for _, day := range days {
if day == "*" || day == dayName {
return true
}
}
return false
}
// NextScheduledTime calculates the next time a scheduled time will occur
func NextScheduledTime(now time.Time, timeStr string, days []string, loc *time.Location) (time.Time, error) {
hour, minute, err := ParseTime(timeStr)
if err != nil {
return time.Time{}, err
}
nowInLoc := now.In(loc)
// Start from today at the specified time
next := time.Date(nowInLoc.Year(), nowInLoc.Month(), nowInLoc.Day(), hour, minute, 0, 0, loc)
// If the time has passed today, start from tomorrow
if next.Before(nowInLoc) || next.Equal(nowInLoc) {
next = next.Add(24 * time.Hour)
}
// Find the next matching day (within 7 days)
for i := 0; i < 7; i++ {
if IsDayMatch(next, days) {
return next, nil
}
next = next.Add(24 * time.Hour)
}
// If no matching day found (should not happen with valid days), return the calculated time
return next, nil
}

View file

@ -0,0 +1,299 @@
package utils_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/robot/utils"
)
// ID tests
func TestNewID(t *testing.T) {
id1 := utils.NewID()
id2 := utils.NewID()
assert.NotEmpty(t, id1)
assert.NotEmpty(t, id2)
assert.NotEqual(t, id1, id2, "IDs should be unique")
}
func TestNewIDWithPrefix(t *testing.T) {
id := utils.NewIDWithPrefix("exec_")
assert.NotEmpty(t, id)
assert.Contains(t, id, "exec_")
}
// Time tests
func TestParseTime(t *testing.T) {
t.Run("valid time", func(t *testing.T) {
hour, minute, err := utils.ParseTime("14:30")
assert.NoError(t, err)
assert.Equal(t, 14, hour)
assert.Equal(t, 30, minute)
})
t.Run("invalid format", func(t *testing.T) {
_, _, err := utils.ParseTime("14-30")
assert.Error(t, err)
})
t.Run("invalid hour", func(t *testing.T) {
_, _, err := utils.ParseTime("25:30")
assert.Error(t, err)
})
t.Run("invalid minute", func(t *testing.T) {
_, _, err := utils.ParseTime("14:65")
assert.Error(t, err)
})
}
func TestFormatTime(t *testing.T) {
result := utils.FormatTime(9, 5)
assert.Equal(t, "09:05", result)
result = utils.FormatTime(14, 30)
assert.Equal(t, "14:30", result)
}
func TestLoadLocation(t *testing.T) {
t.Run("valid timezone", func(t *testing.T) {
loc := utils.LoadLocation("Asia/Shanghai")
assert.NotNil(t, loc)
assert.Equal(t, "Asia/Shanghai", loc.String())
})
t.Run("empty timezone returns Local", func(t *testing.T) {
loc := utils.LoadLocation("")
assert.Equal(t, time.Local, loc)
})
t.Run("invalid timezone returns Local", func(t *testing.T) {
loc := utils.LoadLocation("Invalid/Timezone")
assert.Equal(t, time.Local, loc)
})
}
func TestParseDuration(t *testing.T) {
t.Run("valid duration", func(t *testing.T) {
dur := utils.ParseDuration("30m", 10*time.Minute)
assert.Equal(t, 30*time.Minute, dur)
})
t.Run("empty returns default", func(t *testing.T) {
dur := utils.ParseDuration("", 10*time.Minute)
assert.Equal(t, 10*time.Minute, dur)
})
t.Run("invalid returns default", func(t *testing.T) {
dur := utils.ParseDuration("invalid", 10*time.Minute)
assert.Equal(t, 10*time.Minute, dur)
})
}
func TestIsTimeMatch(t *testing.T) {
loc := time.UTC
testTime := time.Date(2024, 1, 15, 14, 30, 0, 0, loc)
t.Run("exact match", func(t *testing.T) {
assert.True(t, utils.IsTimeMatch(testTime, "14:30", loc))
})
t.Run("no match", func(t *testing.T) {
assert.False(t, utils.IsTimeMatch(testTime, "14:31", loc))
assert.False(t, utils.IsTimeMatch(testTime, "15:30", loc))
})
t.Run("invalid time format", func(t *testing.T) {
assert.False(t, utils.IsTimeMatch(testTime, "invalid", loc))
})
}
func TestIsDayMatch(t *testing.T) {
monday := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) // Monday
t.Run("match specific day", func(t *testing.T) {
assert.True(t, utils.IsDayMatch(monday, []string{"Mon"}))
})
t.Run("match wildcard", func(t *testing.T) {
assert.True(t, utils.IsDayMatch(monday, []string{"*"}))
})
t.Run("no match", func(t *testing.T) {
assert.False(t, utils.IsDayMatch(monday, []string{"Tue", "Wed"}))
})
t.Run("empty days returns true", func(t *testing.T) {
assert.True(t, utils.IsDayMatch(monday, []string{}))
})
}
// Convert tests
func TestToJSON(t *testing.T) {
data := map[string]interface{}{
"name": "test",
"age": 30,
}
json, err := utils.ToJSON(data)
assert.NoError(t, err)
assert.Contains(t, json, "test")
assert.Contains(t, json, "30")
}
func TestFromJSON(t *testing.T) {
jsonStr := `{"name":"test","age":30}`
var result map[string]interface{}
err := utils.FromJSON(jsonStr, &result)
assert.NoError(t, err)
assert.Equal(t, "test", result["name"])
assert.Equal(t, float64(30), result["age"]) // JSON numbers are float64
}
func TestToMap(t *testing.T) {
type TestStruct struct {
Name string `json:"name"`
Age int `json:"age"`
}
s := TestStruct{Name: "test", Age: 30}
m, err := utils.ToMap(s)
assert.NoError(t, err)
assert.Equal(t, "test", m["name"])
assert.Equal(t, float64(30), m["age"]) // JSON conversion makes it float64
}
func TestFromMap(t *testing.T) {
type TestStruct struct {
Name string `json:"name"`
Age int `json:"age"`
}
m := map[string]interface{}{
"name": "test",
"age": 30,
}
var result TestStruct
err := utils.FromMap(m, &result)
assert.NoError(t, err)
assert.Equal(t, "test", result.Name)
assert.Equal(t, 30, result.Age)
}
func TestToString(t *testing.T) {
assert.Equal(t, "test", utils.ToString("test"))
assert.Equal(t, "42", utils.ToString(42))
assert.Equal(t, "true", utils.ToString(true))
}
func TestMergeMap(t *testing.T) {
target := map[string]interface{}{
"a": 1,
"b": 2,
}
source := map[string]interface{}{
"b": 3,
"c": 4,
}
result := utils.MergeMap(target, source)
assert.Equal(t, 1, result["a"])
assert.Equal(t, 3, result["b"]) // overwritten
assert.Equal(t, 4, result["c"])
}
func TestCloneMap(t *testing.T) {
original := map[string]interface{}{
"a": 1,
"b": 2,
}
cloned := utils.CloneMap(original)
cloned["a"] = 999
assert.Equal(t, 1, original["a"]) // original unchanged
assert.Equal(t, 999, cloned["a"])
}
// Validate tests
func TestIsEmpty(t *testing.T) {
assert.True(t, utils.IsEmpty(""))
assert.False(t, utils.IsEmpty("test"))
}
func TestIsValidEmail(t *testing.T) {
assert.True(t, utils.IsValidEmail("test@example.com"))
assert.True(t, utils.IsValidEmail("user+tag@domain.co.uk"))
assert.False(t, utils.IsValidEmail("invalid"))
assert.False(t, utils.IsValidEmail("@example.com"))
assert.False(t, utils.IsValidEmail("test@"))
}
func TestIsValidTime(t *testing.T) {
assert.True(t, utils.IsValidTime("09:00"))
assert.True(t, utils.IsValidTime("14:30"))
assert.True(t, utils.IsValidTime("23:59"))
assert.False(t, utils.IsValidTime("25:00"))
assert.False(t, utils.IsValidTime("14:65"))
assert.False(t, utils.IsValidTime("14-30"))
}
func TestValidateRequired(t *testing.T) {
t.Run("nil value", func(t *testing.T) {
err := utils.ValidateRequired("field", nil)
assert.Error(t, err)
})
t.Run("empty string", func(t *testing.T) {
err := utils.ValidateRequired("field", "")
assert.Error(t, err)
})
t.Run("valid string", func(t *testing.T) {
err := utils.ValidateRequired("field", "value")
assert.NoError(t, err)
})
t.Run("empty slice", func(t *testing.T) {
err := utils.ValidateRequired("field", []string{})
assert.Error(t, err)
})
}
func TestValidateRange(t *testing.T) {
t.Run("within range", func(t *testing.T) {
err := utils.ValidateRange("field", 5, 1, 10)
assert.NoError(t, err)
})
t.Run("below range", func(t *testing.T) {
err := utils.ValidateRange("field", 0, 1, 10)
assert.Error(t, err)
})
t.Run("above range", func(t *testing.T) {
err := utils.ValidateRange("field", 11, 1, 10)
assert.Error(t, err)
})
}
func TestValidateOneOf(t *testing.T) {
allowed := []string{"apple", "banana", "cherry"}
t.Run("valid value", func(t *testing.T) {
err := utils.ValidateOneOf("field", "banana", allowed)
assert.NoError(t, err)
})
t.Run("invalid value", func(t *testing.T) {
err := utils.ValidateOneOf("field", "orange", allowed)
assert.Error(t, err)
})
}

View file

@ -0,0 +1,87 @@
package utils
import (
"fmt"
"regexp"
)
var (
// Email regex pattern
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// Time pattern (HH:MM)
timeRegex = regexp.MustCompile(`^([01]?[0-9]|2[0-3]):[0-5][0-9]$`)
)
// IsEmpty checks if a string is empty or whitespace only
func IsEmpty(s string) bool {
return len(s) == 0
}
// IsValidEmail validates email format
func IsValidEmail(email string) bool {
return emailRegex.MatchString(email)
}
// IsValidTime validates time format (HH:MM)
func IsValidTime(timeStr string) bool {
return timeRegex.MatchString(timeStr)
}
// ValidateRequired checks if required fields are present
func ValidateRequired(fieldName string, value interface{}) error {
if value == nil {
return fmt.Errorf("%s is required", fieldName)
}
switch v := value.(type) {
case string:
if IsEmpty(v) {
return fmt.Errorf("%s is required", fieldName)
}
case []string:
if len(v) == 0 {
return fmt.Errorf("%s is required", fieldName)
}
case map[string]interface{}:
if len(v) == 0 {
return fmt.Errorf("%s is required", fieldName)
}
}
return nil
}
// ValidateRange checks if a number is within range
func ValidateRange(fieldName string, value, min, max int) error {
if value < min || value > max {
return fmt.Errorf("%s must be between %d and %d", fieldName, min, max)
}
return nil
}
// ValidateOneOf checks if value is one of allowed values
func ValidateOneOf(fieldName string, value string, allowed []string) error {
for _, a := range allowed {
if value == a {
return nil
}
}
return fmt.Errorf("%s must be one of: %v", fieldName, allowed)
}
// ValidateEmail validates email and returns error if invalid
func ValidateEmail(fieldName string, email string) error {
if !IsValidEmail(email) {
return fmt.Errorf("%s is not a valid email", fieldName)
}
return nil
}
// ValidateTimeFormat validates time format (HH:MM)
func ValidateTimeFormat(fieldName string, timeStr string) error {
if !IsValidTime(timeStr) {
return fmt.Errorf("%s must be in HH:MM format", fieldName)
}
return nil
}