Implement Phase 4: Agent Call Infrastructure and Update TODO.md
- Established a unified calling mechanism for agents, enabling streaming support and multi-turn conversations. - Developed input formatters for various phases, ensuring proper data preparation for assistant prompts. - Created test assistants for single and multi-turn interactions, along with comprehensive test cases for the AgentCaller and InputFormatter. - Updated the TODO.md to reflect the new structure and progress of the agent call infrastructure, including future phases for assistant setup and implementation.
This commit is contained in:
parent
00851c442e
commit
bcb04f8677
5 changed files with 2136 additions and 76 deletions
|
|
@ -369,164 +369,324 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub)
|
|||
|
||||
---
|
||||
|
||||
## Phase 4: Executor - P0 Inspiration
|
||||
## Phase 4: Agent Call Infrastructure ✅
|
||||
|
||||
**Goal:** Implement P0 (Inspiration Agent). Clock trigger → P0 → stub P1-P5.
|
||||
**Goal:** Implement unified Agent/Assistant calling mechanism. This is the foundation for all phase implementations (P0-P5).
|
||||
|
||||
### 4.1 Test Assistant Setup
|
||||
**Architecture Note:**
|
||||
|
||||
- **Prompt construction is handled by Assistant layer** (`prompts.yml` in each assistant)
|
||||
- **Executor only prepares input data** (ClockContext, InspirationReport, etc.) and calls Assistant
|
||||
- **Assistant framework handles** prompt rendering, LLM API calls, streaming
|
||||
|
||||
**Implemented:**
|
||||
|
||||
1. A unified way to call assistants with streaming support
|
||||
2. Input data formatting for each phase
|
||||
3. Response parsing (markdown and structured data via `gou/text`)
|
||||
4. Multi-turn conversation support
|
||||
|
||||
### 4.1 Agent Caller Implementation ✅
|
||||
|
||||
- [x] `executor/agent.go` - `AgentCaller` struct with `SkipOutput`, `SkipHistory`, `SkipSearch`, `ChatID`
|
||||
- [x] `executor/agent.go` - `Call(ctx, assistantID, messages)` - basic call with full response
|
||||
- [x] `executor/agent.go` - `CallWithMessages(ctx, assistantID, userContent)` - convenience method
|
||||
- [x] `executor/agent.go` - `CallWithSystemAndUser(ctx, assistantID, systemContent, userContent)`
|
||||
- [x] `executor/agent.go` - handle assistant not found error
|
||||
- [x] `executor/agent.go` - handle LLM API errors gracefully
|
||||
- [x] `executor/agent.go` - `CallResult.GetJSON()` / `GetJSONArray()` - parse JSON response using `gou/text`
|
||||
- [x] `executor/agent.go` - `Conversation` struct for multi-turn dialogues
|
||||
- [x] `executor/agent.go` - `Conversation.Turn()`, `RunUntil()`, `Reset()`, `WithSystemPrompt()`
|
||||
- [x] `executor/agent.go` - Use `agentcontext.Noop()` logger to suppress debug output
|
||||
|
||||
### 4.2 Input Formatters ✅
|
||||
|
||||
- [x] `executor/input.go` - `FormatClockContext(clockCtx, robot)` - format clock context as message content
|
||||
- [x] `executor/input.go` - `FormatInspirationReport(report)` - format P0 output for P1 input
|
||||
- [x] `executor/input.go` - `FormatTriggerInput(input)` - format Human/Event trigger for P1 input
|
||||
- [x] `executor/input.go` - `FormatGoals(goals, robot)` - format P1 output for P2 input
|
||||
- [x] `executor/input.go` - `FormatTasks(tasks)` - format P2 output for P3 input
|
||||
- [x] `executor/input.go` - `FormatTaskResults(results)` - format P3 output for P4/P5 input
|
||||
- [x] `executor/input.go` - `FormatExecutionSummary(exec)` - format full execution for P5 input
|
||||
- [x] `executor/input.go` - `BuildMessages()`, `BuildMessagesWithSystem()` - helper methods
|
||||
|
||||
### 4.3 Test Assistants ✅
|
||||
|
||||
- [x] `yao-dev-app/assistants/tests/robot-single/` - Single-turn test assistant
|
||||
- [x] `yao-dev-app/assistants/tests/robot-conversation/` - Multi-turn conversation test assistant
|
||||
|
||||
### 4.4 Tests ✅
|
||||
|
||||
- [x] `executor/agent_test.go` - 22 test cases for AgentCaller and Conversation
|
||||
- [x] `executor/input_test.go` - 20 test cases for InputFormatter
|
||||
- [x] Verify: assistant can be called and returns response
|
||||
- [x] Verify: multi-turn conversation maintains state
|
||||
- [x] Verify: input data is well-formatted for assistant prompts
|
||||
- [x] Verify: JSON/YAML extraction from LLM output works correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Test Scenario & Assistants Setup
|
||||
|
||||
**Goal:** Create a realistic test scenario with all required assistants.
|
||||
|
||||
**Test Scenario: Sales Analyst Robot**
|
||||
|
||||
A Sales Analyst robot that:
|
||||
|
||||
- Wakes up at 09:00 on weekdays
|
||||
- Checks sales data and market news
|
||||
- Generates daily goals based on findings
|
||||
- Creates 2-3 actionable tasks
|
||||
- (Future: executes tasks, delivers report, learns)
|
||||
|
||||
Example flow:
|
||||
|
||||
```
|
||||
Clock: Monday 09:00
|
||||
↓
|
||||
P0 Inspiration:
|
||||
- Clock: Monday morning, start of week
|
||||
- Data: 15 new orders (+20% vs last week)
|
||||
- News: Competitor launched new product
|
||||
↓
|
||||
P1 Goals:
|
||||
1. [High] Analyze weekend sales spike
|
||||
2. [Normal] Review competitor product launch
|
||||
3. [Low] Update weekly forecast
|
||||
↓
|
||||
P2 Tasks:
|
||||
Task 1: Query sales DB for weekend orders
|
||||
Task 2: Search web for competitor news
|
||||
Task 3: Generate forecast update
|
||||
```
|
||||
|
||||
### 5.1 Test Assistant Directory Structure
|
||||
|
||||
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
|
||||
```
|
||||
assistants/robot/
|
||||
├── inspiration/ # P0: Inspiration Agent
|
||||
│ ├── package.yao # Assistant config
|
||||
│ └── prompts.yml # System prompt & templates
|
||||
├── goals/ # P1: Goal Generation Agent
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
├── tasks/ # P2: Task Planning Agent
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
├── validation/ # P3: Validation Agent (future)
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
├── delivery/ # P4: Delivery Agent (future)
|
||||
│ ├── package.yao
|
||||
│ └── prompts.yml
|
||||
└── learning/ # P5: Learning Agent (future)
|
||||
├── package.yao
|
||||
└── prompts.yml
|
||||
```
|
||||
|
||||
### 4.2 P0 Implementation
|
||||
### 5.2 Inspiration Assistant (P0)
|
||||
|
||||
- [ ] `executor/inspiration.go` - build prompt with `ClockContext`
|
||||
- [ ] `executor/inspiration.go` - call Inspiration Agent
|
||||
- [ ] `executor/inspiration.go` - parse response to `InspirationReport`
|
||||
- [ ] `executor/prompt.go` - `BuildInspirationPrompt()`
|
||||
- [ ] `robot/inspiration/package.yao` - config with model, temperature
|
||||
- [ ] `robot/inspiration/prompts.yml` - system prompt for P0:
|
||||
- Input: Clock context, robot identity, data sources
|
||||
- Output: Markdown report with Summary, Highlights, Opportunities, Risks
|
||||
|
||||
### 4.3 Tests
|
||||
### 5.3 Goals Assistant (P1)
|
||||
|
||||
- [ ] `executor/inspiration_test.go` - P0 with real LLM call
|
||||
- [ ] Verify: clock context in prompt
|
||||
- [ ] Verify: markdown report generated
|
||||
- [ ] `robot/goals/package.yao` - config
|
||||
- [ ] `robot/goals/prompts.yml` - system prompt for P1:
|
||||
- Input: Inspiration report, robot duties
|
||||
- Output: Prioritized goals in markdown format
|
||||
|
||||
### 5.4 Tasks Assistant (P2)
|
||||
|
||||
- [ ] `robot/tasks/package.yao` - config
|
||||
- [ ] `robot/tasks/prompts.yml` - system prompt for P2:
|
||||
- Input: Goals, available tools/agents
|
||||
- Output: Structured task list (JSON)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Executor - P1 Goals
|
||||
## Phase 6: P0 Inspiration Implementation
|
||||
|
||||
**Goal:** Implement P0 (Inspiration Agent). Clock trigger → P0 → stub P1-P5.
|
||||
|
||||
**Depends on:** Phase 4 (Agent Call Infrastructure), Phase 5 (Assistants Setup)
|
||||
|
||||
### 6.1 P0 Implementation
|
||||
|
||||
- [ ] `executor/inspiration.go` - `RunInspiration(ctx, exec, data)` - real implementation
|
||||
- [ ] `executor/inspiration.go` - build prompt using `PromptBuilder`
|
||||
- [ ] `executor/inspiration.go` - call Inspiration Agent using `AgentCaller`
|
||||
- [ ] `executor/inspiration.go` - parse response to `InspirationReport`
|
||||
- [ ] `executor/inspiration.go` - handle streaming response
|
||||
- [ ] `executor/inspiration.go` - log phase progress to Job system
|
||||
|
||||
### 6.2 Tests
|
||||
|
||||
- [ ] `executor/inspiration_test.go` - P0 with real LLM call
|
||||
- [ ] Test: clock context correctly formatted in prompt
|
||||
- [ ] Test: robot identity included in system prompt
|
||||
- [ ] Test: markdown report generated with expected sections
|
||||
- [ ] Test: handles LLM errors gracefully
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: P1 Goals Implementation
|
||||
|
||||
**Goal:** Implement P1 (Goal Generation Agent). P0 → P1 → stub P2-P5.
|
||||
|
||||
### 5.1 Test Assistant Setup
|
||||
**Depends on:** Phase 6 (P0 Inspiration)
|
||||
|
||||
- [ ] `goals/package.yao` - Goal Generation Agent config
|
||||
- [ ] `goals/prompts.yml` - P1 prompts
|
||||
|
||||
### 5.2 P1 Implementation
|
||||
### 7.1 P1 Implementation
|
||||
|
||||
- [ ] `executor/goals.go` - `RunGoals(ctx, exec, data)` - real implementation
|
||||
- [ ] `executor/goals.go` - build prompt with inspiration report
|
||||
- [ ] `executor/goals.go` - call Goal Agent
|
||||
- [ ] `executor/goals.go` - parse response to `Goals` (markdown)
|
||||
- [ ] `executor/prompt.go` - `BuildGoalsPrompt()`
|
||||
- [ ] `executor/goals.go` - call Goals Agent
|
||||
- [ ] `executor/goals.go` - parse response to `Goals` struct
|
||||
- [ ] `executor/goals.go` - handle Human/Event trigger (skip P0, use input directly)
|
||||
|
||||
### 5.3 Tests
|
||||
### 7.2 Tests
|
||||
|
||||
- [ ] `executor/goals_test.go` - P1 with real LLM call
|
||||
- [ ] Verify: inspiration report in prompt
|
||||
- [ ] Verify: goals markdown generated
|
||||
- [ ] Test: inspiration report in prompt (Clock trigger)
|
||||
- [ ] Test: user input in prompt (Human trigger)
|
||||
- [ ] Test: goals markdown generated with priorities
|
||||
- [ ] Test: goals are actionable and measurable
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Executor - P2 Tasks
|
||||
## Phase 8: P2 Tasks Implementation
|
||||
|
||||
**Goal:** Implement P2 (Task Planning Agent). P1 → P2 → stub P3-P5.
|
||||
|
||||
### 6.1 Test Assistant Setup
|
||||
**Depends on:** Phase 7 (P1 Goals)
|
||||
|
||||
- [ ] `tasks/package.yao` - Task Planning Agent config
|
||||
- [ ] `tasks/prompts.yml` - P2 prompts
|
||||
|
||||
### 6.2 P2 Implementation
|
||||
### 8.1 P2 Implementation
|
||||
|
||||
- [ ] `executor/tasks.go` - `RunTasks(ctx, exec, data)` - real implementation
|
||||
- [ ] `executor/tasks.go` - build prompt with goals
|
||||
- [ ] `executor/tasks.go` - call Task Agent
|
||||
- [ ] `executor/tasks.go` - parse response to `[]Task` (structured)
|
||||
- [ ] `executor/prompt.go` - `BuildTasksPrompt()`
|
||||
- [ ] `executor/tasks.go` - include available tools/agents in prompt
|
||||
- [ ] `executor/tasks.go` - call Tasks Agent
|
||||
- [ ] `executor/tasks.go` - parse response to `[]Task` (structured JSON)
|
||||
- [ ] `executor/tasks.go` - validate task structure
|
||||
|
||||
### 6.3 Tests
|
||||
### 8.2 Tests
|
||||
|
||||
- [ ] `executor/tasks_test.go` - P2 with real LLM call
|
||||
- [ ] Verify: goals in prompt
|
||||
- [ ] Verify: structured tasks generated
|
||||
- [ ] Test: goals included in prompt
|
||||
- [ ] Test: available tools listed in prompt
|
||||
- [ ] Test: structured tasks generated (2-3 tasks per goal)
|
||||
- [ ] Test: each task has valid executor type and ID
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Executor - P3 Run
|
||||
## Phase 9: P3 Run Implementation
|
||||
|
||||
**Goal:** Implement P3 (Task Execution). P2 → P3 → stub P4-P5.
|
||||
|
||||
### 7.1 Implementation
|
||||
**Depends on:** Phase 8 (P2 Tasks)
|
||||
|
||||
- [ ] `executor/run.go` - iterate tasks
|
||||
- [ ] `executor/run.go` - call executor (assistant/mcp/process)
|
||||
- [ ] `executor/run.go` - collect results
|
||||
- [ ] `executor/agent.go` - unified agent call method
|
||||
### 9.1 Implementation
|
||||
|
||||
### 7.2 Validation Agent Setup
|
||||
- [ ] `executor/run.go` - `RunExecution(ctx, exec, data)` - real implementation
|
||||
- [ ] `executor/run.go` - iterate tasks in order
|
||||
- [ ] `executor/run.go` - dispatch to correct executor (assistant/mcp/process)
|
||||
- [ ] `executor/run.go` - collect results with timing
|
||||
- [ ] `executor/run.go` - handle task failures gracefully
|
||||
- [ ] `executor/run.go` - support pause/resume during execution
|
||||
|
||||
- [ ] `validation/package.yao` - Validation Agent config
|
||||
- [ ] `validation/prompts.yml` - validation prompts
|
||||
### 9.2 Validation Agent Setup
|
||||
|
||||
### 7.3 Tests
|
||||
- [ ] `robot/validation/package.yao` - Validation Agent config
|
||||
- [ ] `robot/validation/prompts.yml` - validation prompts
|
||||
|
||||
### 9.3 Tests
|
||||
|
||||
- [ ] `executor/run_test.go` - P3 with real agent calls
|
||||
- [ ] Verify: tasks executed in order
|
||||
- [ ] Verify: results collected
|
||||
- [ ] Test: tasks executed in order
|
||||
- [ ] Test: results collected with correct structure
|
||||
- [ ] Test: task failure doesn't stop entire execution
|
||||
- [ ] Test: pause/resume works during task execution
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Executor - P4 Delivery
|
||||
## Phase 10: P4 Delivery Implementation
|
||||
|
||||
**Goal:** Implement P4 (Delivery). P3 → P4 → stub P5.
|
||||
|
||||
### 8.1 Test Assistant Setup
|
||||
**Depends on:** Phase 9 (P3 Run)
|
||||
|
||||
- [ ] `delivery/package.yao` - Delivery Agent config
|
||||
- [ ] `delivery/prompts.yml` - delivery prompts
|
||||
### 10.1 Delivery Agent Setup
|
||||
|
||||
### 8.2 Implementation
|
||||
- [ ] `robot/delivery/package.yao` - Delivery Agent config
|
||||
- [ ] `robot/delivery/prompts.yml` - delivery prompts
|
||||
|
||||
- [ ] `executor/delivery.go` - build delivery content
|
||||
- [ ] `executor/delivery.go` - send via configured channel (email/file/webhook/notify)
|
||||
### 10.2 Implementation
|
||||
|
||||
### 8.3 Tests
|
||||
- [ ] `executor/delivery.go` - `RunDelivery(ctx, exec, data)` - real implementation
|
||||
- [ ] `executor/delivery.go` - build delivery content from results
|
||||
- [ ] `executor/delivery.go` - support email delivery
|
||||
- [ ] `executor/delivery.go` - support file delivery
|
||||
- [ ] `executor/delivery.go` - support webhook delivery
|
||||
- [ ] `executor/delivery.go` - support notify delivery
|
||||
|
||||
### 10.3 Tests
|
||||
|
||||
- [ ] `executor/delivery_test.go` - P4 delivery
|
||||
- [ ] Verify: delivery sent (mock or real)
|
||||
- [ ] Test: delivery content generated correctly
|
||||
- [ ] Test: email delivery (mock or real)
|
||||
- [ ] Test: file delivery to configured path
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Executor - P5 Learning
|
||||
## Phase 11: P5 Learning Implementation
|
||||
|
||||
**Goal:** Implement P5 (Learning). Full execution flow complete.
|
||||
|
||||
### 9.1 Test Assistant Setup
|
||||
**Depends on:** Phase 10 (P4 Delivery)
|
||||
|
||||
- [ ] `learning/package.yao` - Learning Agent config
|
||||
- [ ] `learning/prompts.yml` - learning prompts
|
||||
### 11.1 Learning Agent Setup
|
||||
|
||||
### 9.2 Store Implementation
|
||||
- [ ] `robot/learning/package.yao` - Learning Agent config
|
||||
- [ ] `robot/learning/prompts.yml` - learning prompts
|
||||
|
||||
### 11.2 Store Implementation
|
||||
|
||||
- [ ] `store/store.go` - Store interface and struct
|
||||
- [ ] `store/kb.go` - KB operations (create, save, search)
|
||||
- [ ] `store/learning.go` - save learning entries to private KB
|
||||
|
||||
### 9.3 Implementation
|
||||
### 11.3 Implementation
|
||||
|
||||
- [ ] `executor/learning.go` - `RunLearning(ctx, exec, data)` - real implementation
|
||||
- [ ] `executor/learning.go` - extract learnings from execution
|
||||
- [ ] `executor/learning.go` - call Learning Agent
|
||||
- [ ] `executor/learning.go` - save to private KB
|
||||
|
||||
### 9.4 Tests
|
||||
### 11.4 Tests
|
||||
|
||||
- [ ] `executor/learning_test.go` - P5 learning
|
||||
- [ ] Verify: learnings saved to KB
|
||||
- [ ] Test: learnings extracted from execution
|
||||
- [ ] Test: learnings saved to KB
|
||||
- [ ] Test: KB can be queried for past learnings
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: API & Integration
|
||||
## Phase 12: API & Integration
|
||||
|
||||
**Goal:** Complete API implementation, end-to-end tests.
|
||||
|
||||
### 10.1 API Implementation
|
||||
### 12.1 API Implementation
|
||||
|
||||
- [ ] `api/api.go` - implement all Go API functions
|
||||
- [ ] `api/process.go` - implement all Process handlers
|
||||
- [ ] `api/jsapi.go` - implement JSAPI
|
||||
|
||||
### 10.2 End-to-End Tests
|
||||
### 12.2 End-to-End Tests
|
||||
|
||||
- [ ] Full clock trigger flow (P0 → P5)
|
||||
- [ ] Human intervention flow (P1 → P5)
|
||||
|
|
@ -534,18 +694,18 @@ Create `yao-dev-app/assistants/robot/` directory:
|
|||
- [ ] Concurrent execution test
|
||||
- [ ] Pause/Resume/Stop test
|
||||
|
||||
### 10.3 Integration with OpenAPI
|
||||
### 12.3 Integration with OpenAPI
|
||||
|
||||
- [ ] HTTP endpoints for human intervention
|
||||
- [ ] Webhook endpoints for events
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Advanced Features
|
||||
## Phase 13: Advanced Features
|
||||
|
||||
**Goal:** Implement dedup, semantic dedup, plan queue.
|
||||
|
||||
### 11.1 Fast Dedup (Time-Window)
|
||||
### 13.1 Fast Dedup (Time-Window)
|
||||
|
||||
> **Note:** Manager has `// TODO: dedup check` comment placeholder. Integrate after implementation.
|
||||
|
||||
|
|
@ -557,13 +717,13 @@ Create `yao-dev-app/assistants/robot/` directory:
|
|||
- [ ] Integrate into Manager.Tick()
|
||||
- [ ] Test: dedup check/mark, window expiry
|
||||
|
||||
### 11.2 Semantic Dedup
|
||||
### 13.2 Semantic Dedup
|
||||
|
||||
- [ ] `dedup/semantic.go` - call Dedup Agent for goal/task level dedup
|
||||
- [ ] Dedup Agent setup (`assistants/robot/dedup/`)
|
||||
- [ ] Test: semantic dedup with real LLM
|
||||
|
||||
### 11.3 Plan Queue
|
||||
### 13.3 Plan Queue
|
||||
|
||||
- [ ] `plan/plan.go` - plan queue implementation
|
||||
- [ ] Store planned tasks/goals
|
||||
|
|
|
|||
475
agent/robot/executor/agent.go
Normal file
475
agent/robot/executor/agent.go
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/text"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// AgentCaller provides unified interface for calling AI assistants
|
||||
// It wraps the Yao Assistant framework and handles:
|
||||
// - Getting assistant by ID
|
||||
// - Single call with messages (streaming)
|
||||
// - Multi-turn conversation with session state
|
||||
// - Parsing responses (text, JSON, Next hook data)
|
||||
type AgentCaller struct {
|
||||
// SkipOutput skips sending output to client (for internal calls)
|
||||
SkipOutput bool
|
||||
|
||||
// SkipHistory skips saving to chat history (default: true for robot)
|
||||
// Set to false to enable multi-turn conversation with history
|
||||
SkipHistory bool
|
||||
|
||||
// SkipSearch skips auto search
|
||||
SkipSearch bool
|
||||
|
||||
// ChatID is used for multi-turn conversations to maintain session state
|
||||
// If empty, each call is independent (no history)
|
||||
ChatID string
|
||||
}
|
||||
|
||||
// NewAgentCaller creates a new AgentCaller with default settings (single-call mode)
|
||||
func NewAgentCaller() *AgentCaller {
|
||||
return &AgentCaller{
|
||||
SkipOutput: true, // Robot executions don't send to UI
|
||||
SkipHistory: true, // Robot executions don't save to chat history
|
||||
SkipSearch: true, // Robot executions don't trigger auto search
|
||||
}
|
||||
}
|
||||
|
||||
// NewConversationCaller creates an AgentCaller for multi-turn conversations
|
||||
// chatID is used to maintain session state across calls
|
||||
// This is useful for:
|
||||
// - P2 (Tasks): Iterative task refinement with user feedback
|
||||
// - P3 (Run): Multi-step task execution with intermediate results
|
||||
func NewConversationCaller(chatID string) *AgentCaller {
|
||||
return &AgentCaller{
|
||||
SkipOutput: true,
|
||||
SkipHistory: false, // Enable history for multi-turn
|
||||
SkipSearch: true,
|
||||
ChatID: chatID,
|
||||
}
|
||||
}
|
||||
|
||||
// CallResult holds the result of an agent call
|
||||
type CallResult struct {
|
||||
// Content is the raw text content from LLM completion
|
||||
Content string
|
||||
|
||||
// Next is the data returned from Next hook (if any)
|
||||
// This is typically a structured response from the assistant
|
||||
Next interface{}
|
||||
|
||||
// Response is the full response object (for advanced use)
|
||||
Response *agentcontext.Response
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the result has no content
|
||||
func (r *CallResult) IsEmpty() bool {
|
||||
return r.Content == "" && r.Next == nil
|
||||
}
|
||||
|
||||
// GetText returns the text content, preferring Content over Next
|
||||
func (r *CallResult) GetText() string {
|
||||
if r.Content != "" {
|
||||
return r.Content
|
||||
}
|
||||
// If Next is a string, return it
|
||||
if s, ok := r.Next.(string); ok {
|
||||
return s
|
||||
}
|
||||
// If Next has a "content" field, return it
|
||||
if m, ok := r.Next.(map[string]interface{}); ok {
|
||||
if content, ok := m["content"].(string); ok {
|
||||
return content
|
||||
}
|
||||
// Also check "data" field (common pattern in Next hook)
|
||||
if data, ok := m["data"].(map[string]interface{}); ok {
|
||||
if content, ok := data["content"].(string); ok {
|
||||
return content
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetJSON attempts to parse the result as JSON
|
||||
// It tries in order:
|
||||
// 1. Next hook data (already structured)
|
||||
// 2. Content parsed using gou/text.ExtractJSON (fault-tolerant)
|
||||
// Returns the parsed data and any error
|
||||
func (r *CallResult) GetJSON() (map[string]interface{}, error) {
|
||||
// Try Next hook data first
|
||||
if r.Next != nil {
|
||||
if m, ok := r.Next.(map[string]interface{}); ok {
|
||||
// Check for "data" wrapper (common in Next hook)
|
||||
if data, ok := m["data"].(map[string]interface{}); ok {
|
||||
return data, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try parsing Content using gou/text (handles markdown blocks, JSON, YAML)
|
||||
if r.Content != "" {
|
||||
data := text.ExtractJSON(r.Content)
|
||||
if data != nil {
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("content is not a JSON object")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no content to parse")
|
||||
}
|
||||
|
||||
// GetJSONArray attempts to parse the result as JSON array
|
||||
// Similar to GetJSON but for array responses
|
||||
func (r *CallResult) GetJSONArray() ([]interface{}, error) {
|
||||
// Try Next hook data first
|
||||
if r.Next != nil {
|
||||
if arr, ok := r.Next.([]interface{}); ok {
|
||||
return arr, nil
|
||||
}
|
||||
if m, ok := r.Next.(map[string]interface{}); ok {
|
||||
// Check for "data" wrapper
|
||||
if data, ok := m["data"].([]interface{}); ok {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try parsing Content using gou/text (handles markdown blocks, JSON, YAML)
|
||||
if r.Content != "" {
|
||||
data := text.ExtractJSON(r.Content)
|
||||
if data != nil {
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
return arr, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("content is not a JSON array")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no content to parse")
|
||||
}
|
||||
|
||||
// Call calls an assistant with messages and returns the result
|
||||
// This is the main entry point for agent calls
|
||||
func (c *AgentCaller) Call(ctx *types.Context, assistantID string, messages []agentcontext.Message) (*CallResult, error) {
|
||||
// Get assistant
|
||||
ast, err := assistant.Get(assistantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assistant not found: %s: %w", assistantID, err)
|
||||
}
|
||||
|
||||
// Build options
|
||||
opts := &agentcontext.Options{
|
||||
Skip: &agentcontext.Skip{
|
||||
Output: c.SkipOutput,
|
||||
History: c.SkipHistory,
|
||||
Search: c.SkipSearch,
|
||||
},
|
||||
}
|
||||
|
||||
// Convert robot context to agent context
|
||||
agentCtx := c.buildAgentContext(ctx)
|
||||
|
||||
// Call assistant with streaming
|
||||
response, err := ast.Stream(agentCtx, messages, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assistant call failed: %w", err)
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := &CallResult{
|
||||
Response: response,
|
||||
}
|
||||
|
||||
// Extract Next hook data
|
||||
if response.Next != nil {
|
||||
result.Next = response.Next
|
||||
}
|
||||
|
||||
// Extract Content from Completion
|
||||
if response.Completion != nil {
|
||||
if content, ok := response.Completion.Content.(string); ok {
|
||||
result.Content = content
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CallWithMessages is a convenience method that builds messages from a single user input
|
||||
func (c *AgentCaller) CallWithMessages(ctx *types.Context, assistantID string, userContent string) (*CallResult, error) {
|
||||
messages := []agentcontext.Message{
|
||||
{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: userContent,
|
||||
},
|
||||
}
|
||||
return c.Call(ctx, assistantID, messages)
|
||||
}
|
||||
|
||||
// CallWithSystemAndUser calls with both system and user messages
|
||||
func (c *AgentCaller) CallWithSystemAndUser(ctx *types.Context, assistantID string, systemContent, userContent string) (*CallResult, error) {
|
||||
messages := []agentcontext.Message{
|
||||
{
|
||||
Role: agentcontext.RoleSystem,
|
||||
Content: systemContent,
|
||||
},
|
||||
{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: userContent,
|
||||
},
|
||||
}
|
||||
return c.Call(ctx, assistantID, messages)
|
||||
}
|
||||
|
||||
// buildAgentContext converts robot context to agent context
|
||||
func (c *AgentCaller) buildAgentContext(ctx *types.Context) *agentcontext.Context {
|
||||
// Build authorized info for agent context
|
||||
var authorized *oauthtypes.AuthorizedInfo
|
||||
if ctx.Auth != nil {
|
||||
authorized = &oauthtypes.AuthorizedInfo{
|
||||
UserID: ctx.Auth.UserID,
|
||||
TeamID: ctx.Auth.TeamID,
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new agent context
|
||||
// Use ChatID for multi-turn conversations, empty for single calls
|
||||
agentCtx := agentcontext.New(ctx.Context, authorized, c.ChatID)
|
||||
|
||||
// Set locale if available
|
||||
if ctx.Locale != "" {
|
||||
agentCtx.Locale = ctx.Locale
|
||||
}
|
||||
|
||||
// Use noop logger to suppress LLM debug output for robot executions
|
||||
// Robot executions run in background and don't need console output
|
||||
if agentCtx.Logger != nil {
|
||||
agentCtx.Logger.Close()
|
||||
}
|
||||
agentCtx.Logger = agentcontext.Noop()
|
||||
|
||||
return agentCtx
|
||||
}
|
||||
|
||||
// ExtractCodeBlock extracts the first code block from content using gou/text
|
||||
// Returns the CodeBlock with type, content, and parsed data (for JSON/YAML)
|
||||
func ExtractCodeBlock(content string) *text.CodeBlock {
|
||||
return text.ExtractFirst(content)
|
||||
}
|
||||
|
||||
// ExtractAllCodeBlocks extracts all code blocks from content using gou/text
|
||||
func ExtractAllCodeBlocks(content string) []text.CodeBlock {
|
||||
return text.Extract(content)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversation - Multi-turn dialogue support
|
||||
// ============================================================================
|
||||
|
||||
// Conversation manages a multi-turn dialogue with an assistant
|
||||
// Useful for:
|
||||
// - P2 (Tasks): Iterative task planning with clarification
|
||||
// - P3 (Run): Multi-step execution with intermediate validation
|
||||
// - Complex reasoning that requires back-and-forth
|
||||
type Conversation struct {
|
||||
caller *AgentCaller
|
||||
assistantID string
|
||||
messages []agentcontext.Message
|
||||
maxTurns int
|
||||
}
|
||||
|
||||
// TurnResult holds the result of a single conversation turn
|
||||
type TurnResult struct {
|
||||
Turn int // Turn number (1-based)
|
||||
Input string // User input for this turn
|
||||
Result *CallResult // Agent response
|
||||
Messages []agentcontext.Message // Full message history after this turn
|
||||
}
|
||||
|
||||
// NewConversation creates a new multi-turn conversation
|
||||
// assistantID: the assistant to converse with
|
||||
// chatID: session ID for maintaining state (use exec.ID for robot executions)
|
||||
// maxTurns: maximum number of turns (0 = unlimited)
|
||||
func NewConversation(assistantID, chatID string, maxTurns int) *Conversation {
|
||||
return &Conversation{
|
||||
caller: NewConversationCaller(chatID),
|
||||
assistantID: assistantID,
|
||||
messages: make([]agentcontext.Message, 0),
|
||||
maxTurns: maxTurns,
|
||||
}
|
||||
}
|
||||
|
||||
// WithCaller sets a custom AgentCaller for the conversation
|
||||
// Useful for customizing SkipSearch or other options
|
||||
func (c *Conversation) WithCaller(caller *AgentCaller) *Conversation {
|
||||
c.caller = caller
|
||||
return c
|
||||
}
|
||||
|
||||
// WithSystemPrompt adds a system prompt at the beginning of the conversation
|
||||
func (c *Conversation) WithSystemPrompt(systemPrompt string) *Conversation {
|
||||
if systemPrompt != "" {
|
||||
c.messages = append([]agentcontext.Message{{
|
||||
Role: agentcontext.RoleSystem,
|
||||
Content: systemPrompt,
|
||||
}}, c.messages...)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// WithHistory initializes the conversation with existing message history
|
||||
// Note: Message structs are copied, but Content (interface{}) is a shallow copy
|
||||
func (c *Conversation) WithHistory(messages []agentcontext.Message) *Conversation {
|
||||
c.messages = append(c.messages, messages...)
|
||||
return c
|
||||
}
|
||||
|
||||
// Turn executes a single turn in the conversation
|
||||
// userInput: the user's message for this turn
|
||||
// Returns the turn result with agent response
|
||||
func (c *Conversation) Turn(ctx *types.Context, userInput string) (*TurnResult, error) {
|
||||
// Check max turns
|
||||
turnNum := c.TurnCount() + 1
|
||||
if c.maxTurns > 0 && turnNum > c.maxTurns {
|
||||
return nil, fmt.Errorf("max turns (%d) exceeded", c.maxTurns)
|
||||
}
|
||||
|
||||
// Build messages with user input (don't modify history yet)
|
||||
userMsg := agentcontext.Message{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: userInput,
|
||||
}
|
||||
// Create a new slice to avoid modifying c.messages if capacity allows append in-place
|
||||
messagesWithInput := make([]agentcontext.Message, len(c.messages)+1)
|
||||
copy(messagesWithInput, c.messages)
|
||||
messagesWithInput[len(c.messages)] = userMsg
|
||||
|
||||
// Call assistant with full history
|
||||
result, err := c.caller.Call(ctx, c.assistantID, messagesWithInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("turn %d failed: %w", turnNum, err)
|
||||
}
|
||||
|
||||
// Only update history after successful call
|
||||
c.messages = append(c.messages, userMsg)
|
||||
|
||||
// Add assistant response to history
|
||||
if result.Content != "" {
|
||||
c.messages = append(c.messages, agentcontext.Message{
|
||||
Role: agentcontext.RoleAssistant,
|
||||
Content: result.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Return a copy of messages to prevent external modification
|
||||
messagesCopy := make([]agentcontext.Message, len(c.messages))
|
||||
copy(messagesCopy, c.messages)
|
||||
|
||||
return &TurnResult{
|
||||
Turn: turnNum,
|
||||
Input: userInput,
|
||||
Result: result,
|
||||
Messages: messagesCopy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TurnCount returns the number of user turns so far
|
||||
func (c *Conversation) TurnCount() int {
|
||||
count := 0
|
||||
for _, msg := range c.messages {
|
||||
if msg.Role == agentcontext.RoleUser {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Messages returns a copy of the current message history
|
||||
func (c *Conversation) Messages() []agentcontext.Message {
|
||||
messagesCopy := make([]agentcontext.Message, len(c.messages))
|
||||
copy(messagesCopy, c.messages)
|
||||
return messagesCopy
|
||||
}
|
||||
|
||||
// LastResponse returns a copy of the last assistant response, or nil if none
|
||||
func (c *Conversation) LastResponse() *agentcontext.Message {
|
||||
for i := len(c.messages) - 1; i >= 0; i-- {
|
||||
if c.messages[i].Role == agentcontext.RoleAssistant {
|
||||
// Return a copy to prevent external modification
|
||||
msg := c.messages[i]
|
||||
return &msg
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reset clears the conversation history (keeps system prompt if any)
|
||||
func (c *Conversation) Reset() {
|
||||
// Keep system prompt if present
|
||||
var systemPrompt *agentcontext.Message
|
||||
if len(c.messages) > 0 && c.messages[0].Role == agentcontext.RoleSystem {
|
||||
systemPrompt = &c.messages[0]
|
||||
}
|
||||
|
||||
c.messages = make([]agentcontext.Message, 0)
|
||||
if systemPrompt != nil {
|
||||
c.messages = append(c.messages, *systemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
// RunUntil runs the conversation until a condition is met
|
||||
// checkFn: called after each turn, returns (done, error)
|
||||
// Returns all turn results
|
||||
func (c *Conversation) RunUntil(
|
||||
ctx *types.Context,
|
||||
inputFn func(turn int, lastResult *CallResult) (string, error),
|
||||
checkFn func(turn int, result *CallResult) (done bool, err error),
|
||||
) ([]*TurnResult, error) {
|
||||
var results []*TurnResult
|
||||
|
||||
for {
|
||||
turnNum := c.TurnCount() + 1
|
||||
|
||||
// Check max turns
|
||||
if c.maxTurns > 0 && turnNum > c.maxTurns {
|
||||
return results, fmt.Errorf("max turns (%d) exceeded without completion", c.maxTurns)
|
||||
}
|
||||
|
||||
// Get input for this turn
|
||||
var lastResult *CallResult
|
||||
if len(results) > 0 {
|
||||
lastResult = results[len(results)-1].Result
|
||||
}
|
||||
|
||||
input, err := inputFn(turnNum, lastResult)
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("input generation failed at turn %d: %w", turnNum, err)
|
||||
}
|
||||
|
||||
// Execute turn
|
||||
turnResult, err := c.Turn(ctx, input)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
results = append(results, turnResult)
|
||||
|
||||
// Check completion condition
|
||||
done, err := checkFn(turnNum, turnResult.Result)
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("check failed at turn %d: %w", turnNum, err)
|
||||
}
|
||||
if done {
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
585
agent/robot/executor/agent_test.go
Normal file
585
agent/robot/executor/agent_test.go
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
package executor_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/robot/executor"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// testAuth returns a test auth info for agent calls
|
||||
func testAuth() *oauthtypes.AuthorizedInfo {
|
||||
return &oauthtypes.AuthorizedInfo{
|
||||
UserID: "test-user-1",
|
||||
TeamID: "test-team-1",
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AgentCaller Tests - Single Call Mode
|
||||
// ============================================================================
|
||||
|
||||
func TestAgentCallerSingleCall(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := executor.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
// Test basic call - verify assistant responds and returns parseable JSON
|
||||
// Note: LLM outputs are non-deterministic, so we test structure not exact values
|
||||
t.Run("basic call returns response", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "Hello, test message")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.IsEmpty(), "result should not be empty")
|
||||
|
||||
// Should be able to get text content
|
||||
text := result.GetText()
|
||||
assert.NotEmpty(t, text, "should have text content")
|
||||
})
|
||||
|
||||
t.Run("call returns parseable JSON", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "Generate inspiration report")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should return parseable JSON (content may vary)
|
||||
data, err := result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, data)
|
||||
// Verify it has "type" field (all test responses should have this)
|
||||
assert.Contains(t, data, "type", "response should have type field")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentCallerNextHookData(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := executor.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("next_hook inspiration returns structured data", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "next_hook inspiration test")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Next hook should return structured data
|
||||
data, err := result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "inspiration", data["type"])
|
||||
assert.Equal(t, "next_hook", data["source"])
|
||||
})
|
||||
|
||||
t.Run("next_hook goals returns structured data", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "next_hook goals test")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
data, err := result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "goals", data["type"])
|
||||
assert.Equal(t, "next_hook", data["source"])
|
||||
})
|
||||
|
||||
t.Run("next_hook tasks returns structured data", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "next_hook tasks test")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
data, err := result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "tasks", data["type"])
|
||||
assert.Equal(t, "next_hook", data["source"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentCallerJSONArray(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := executor.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("array_test returns JSON array", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "array_test")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
arr, err := result.GetJSONArray()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, arr, 3)
|
||||
|
||||
// Verify first item structure
|
||||
item1, ok := arr[0].(map[string]interface{})
|
||||
require.True(t, ok, "first item should be a map")
|
||||
assert.Equal(t, float64(1), item1["id"])
|
||||
assert.Equal(t, "Item 1", item1["name"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentCallerEmptyResponse(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := executor.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("empty_test falls back to completion content", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "tests.robot-single", "empty_test")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
// When Next hook returns null, should use Completion content
|
||||
assert.False(t, result.IsEmpty())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentCallerAssistantNotFound(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := executor.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("non-existent assistant returns error", func(t *testing.T) {
|
||||
result, err := caller.CallWithMessages(ctx, "non.existent.assistant", "hello")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "assistant not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentCallerWithSystemAndUser(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
caller := executor.NewAgentCaller()
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("call with system and user messages", func(t *testing.T) {
|
||||
result, err := caller.CallWithSystemAndUser(
|
||||
ctx,
|
||||
"tests.robot-single",
|
||||
"You are a helpful assistant.",
|
||||
"Generate inspiration report",
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.IsEmpty())
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversation Tests - Multi-Turn Mode
|
||||
// ============================================================================
|
||||
|
||||
func TestConversationMultiTurn(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("multi-turn conversation maintains state", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-1", 10)
|
||||
|
||||
// Turn 1: Start planning
|
||||
turn1, err := conv.Turn(ctx, "Plan tasks for sending weekly report")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, turn1)
|
||||
assert.Equal(t, 1, turn1.Turn)
|
||||
|
||||
data1, err := turn1.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
// Verify basic structure - turn number and completed flag
|
||||
assert.Contains(t, data1, "turn")
|
||||
assert.Contains(t, data1, "status")
|
||||
assert.Contains(t, data1, "completed")
|
||||
|
||||
// Turn 2: Continue conversation
|
||||
turn2, err := conv.Turn(ctx, "Send to managers, include sales data")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, turn2)
|
||||
assert.Equal(t, 2, turn2.Turn)
|
||||
|
||||
data2, err := turn2.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, data2, "turn")
|
||||
assert.Contains(t, data2, "status")
|
||||
|
||||
// Turn 3: Complete with confirm/skip
|
||||
turn3, err := conv.Turn(ctx, "skip") // Use skip for deterministic completion
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, turn3)
|
||||
assert.Equal(t, 3, turn3.Turn)
|
||||
|
||||
data3, err := turn3.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "completed", data3["status"])
|
||||
assert.Equal(t, true, data3["completed"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationTurnCount(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("turn count increments correctly", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-2", 10)
|
||||
|
||||
assert.Equal(t, 0, conv.TurnCount())
|
||||
|
||||
_, err := conv.Turn(ctx, "First message")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, conv.TurnCount())
|
||||
|
||||
_, err = conv.Turn(ctx, "Second message")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, conv.TurnCount())
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationMaxTurns(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("exceeding max turns returns error", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-3", 2)
|
||||
|
||||
_, err := conv.Turn(ctx, "First")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = conv.Turn(ctx, "Second")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Third turn should fail
|
||||
_, err = conv.Turn(ctx, "Third")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "max turns (2) exceeded")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationMessages(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("messages history is maintained", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-4", 5)
|
||||
|
||||
// Initially empty
|
||||
assert.Empty(t, conv.Messages())
|
||||
|
||||
// After first turn
|
||||
_, err := conv.Turn(ctx, "Hello")
|
||||
require.NoError(t, err)
|
||||
|
||||
msgs := conv.Messages()
|
||||
assert.GreaterOrEqual(t, len(msgs), 1) // At least user message
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationLastResponse(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("last response returns assistant message", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-5", 5)
|
||||
|
||||
// No response yet
|
||||
assert.Nil(t, conv.LastResponse())
|
||||
|
||||
// After turn
|
||||
_, err := conv.Turn(ctx, "Start planning")
|
||||
require.NoError(t, err)
|
||||
|
||||
lastResp := conv.LastResponse()
|
||||
assert.NotNil(t, lastResp)
|
||||
assert.Equal(t, "assistant", string(lastResp.Role))
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationReset(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("reset clears conversation history", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-6", 5)
|
||||
|
||||
_, err := conv.Turn(ctx, "First message")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, conv.TurnCount())
|
||||
|
||||
conv.Reset()
|
||||
assert.Equal(t, 0, conv.TurnCount())
|
||||
assert.Empty(t, conv.Messages())
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationWithSystemPrompt(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("system prompt is preserved after reset", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-7", 5).
|
||||
WithSystemPrompt("You are a task planner.")
|
||||
|
||||
msgs := conv.Messages()
|
||||
require.Len(t, msgs, 1)
|
||||
assert.Equal(t, "system", string(msgs[0].Role))
|
||||
|
||||
_, err := conv.Turn(ctx, "Hello")
|
||||
require.NoError(t, err)
|
||||
|
||||
conv.Reset()
|
||||
|
||||
// System prompt should be preserved
|
||||
msgs = conv.Messages()
|
||||
require.Len(t, msgs, 1)
|
||||
assert.Equal(t, "system", string(msgs[0].Role))
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationSpecialCommands(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("skip command jumps to completed", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-8", 5)
|
||||
|
||||
turn, err := conv.Turn(ctx, "skip")
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := turn.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "completed", data["status"])
|
||||
assert.Equal(t, true, data["completed"])
|
||||
})
|
||||
|
||||
t.Run("abort command ends conversation", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-9", 5)
|
||||
|
||||
turn, err := conv.Turn(ctx, "abort")
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := turn.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "aborted", data["status"])
|
||||
assert.Equal(t, true, data["completed"])
|
||||
})
|
||||
|
||||
t.Run("reset command resets conversation state", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-10", 5)
|
||||
|
||||
// First do a turn
|
||||
_, err := conv.Turn(ctx, "Start planning")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then reset via command
|
||||
turn, err := conv.Turn(ctx, "reset")
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := turn.Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "reset", data["status"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestConversationRunUntil(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ctx := types.NewContext(context.Background(), testAuth())
|
||||
|
||||
t.Run("run until completion", func(t *testing.T) {
|
||||
conv := executor.NewConversation("tests.robot-conversation", "test-conv-11", 10)
|
||||
|
||||
inputs := []string{
|
||||
"Plan weekly report tasks",
|
||||
"Send to team leads, include metrics",
|
||||
"confirm",
|
||||
}
|
||||
inputIdx := 0
|
||||
|
||||
results, err := conv.RunUntil(
|
||||
ctx,
|
||||
func(turn int, lastResult *executor.CallResult) (string, error) {
|
||||
if inputIdx < len(inputs) {
|
||||
input := inputs[inputIdx]
|
||||
inputIdx++
|
||||
return input, nil
|
||||
}
|
||||
return "confirm", nil
|
||||
},
|
||||
func(turn int, result *executor.CallResult) (bool, error) {
|
||||
data, err := result.GetJSON()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
completed, ok := data["completed"].(bool)
|
||||
return ok && completed, nil
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 3, "should complete in 3 turns")
|
||||
|
||||
// Final result should be completed
|
||||
finalData, err := results[len(results)-1].Result.GetJSON()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, finalData["completed"])
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CallResult Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestCallResultGetText(t *testing.T) {
|
||||
t.Run("returns content when available", func(t *testing.T) {
|
||||
result := &executor.CallResult{Content: "Hello World"}
|
||||
assert.Equal(t, "Hello World", result.GetText())
|
||||
})
|
||||
|
||||
t.Run("returns empty for empty result", func(t *testing.T) {
|
||||
result := &executor.CallResult{}
|
||||
assert.Equal(t, "", result.GetText())
|
||||
})
|
||||
}
|
||||
|
||||
func TestCallResultIsEmpty(t *testing.T) {
|
||||
t.Run("empty when no content and no next", func(t *testing.T) {
|
||||
result := &executor.CallResult{}
|
||||
assert.True(t, result.IsEmpty())
|
||||
})
|
||||
|
||||
t.Run("not empty when has content", func(t *testing.T) {
|
||||
result := &executor.CallResult{Content: "test"}
|
||||
assert.False(t, result.IsEmpty())
|
||||
})
|
||||
|
||||
t.Run("not empty when has next", func(t *testing.T) {
|
||||
result := &executor.CallResult{Next: map[string]interface{}{"key": "value"}}
|
||||
assert.False(t, result.IsEmpty())
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ExtractCodeBlock Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestExtractCodeBlock(t *testing.T) {
|
||||
t.Run("extracts JSON code block", func(t *testing.T) {
|
||||
content := "Here is the result:\n```json\n{\"key\": \"value\"}\n```"
|
||||
block := executor.ExtractCodeBlock(content)
|
||||
|
||||
require.NotNil(t, block)
|
||||
assert.Equal(t, "json", block.Type)
|
||||
assert.Contains(t, block.Content, "key")
|
||||
})
|
||||
|
||||
t.Run("returns nil for no code block", func(t *testing.T) {
|
||||
content := "Just plain text"
|
||||
block := executor.ExtractCodeBlock(content)
|
||||
|
||||
// gou/text returns text type for plain text
|
||||
require.NotNil(t, block)
|
||||
assert.Equal(t, "text", block.Type)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractAllCodeBlocks(t *testing.T) {
|
||||
t.Run("extracts multiple code blocks", func(t *testing.T) {
|
||||
content := "```json\n{}\n```\n\n```python\nprint('hello')\n```"
|
||||
blocks := executor.ExtractAllCodeBlocks(content)
|
||||
|
||||
assert.Len(t, blocks, 2)
|
||||
})
|
||||
}
|
||||
417
agent/robot/executor/input.go
Normal file
417
agent/robot/executor/input.go
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
package executor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// InputFormatter provides methods to format input data for assistant prompts
|
||||
// Each phase has specific input requirements:
|
||||
// - P0 (Inspiration): ClockContext + Robot identity
|
||||
// - P1 (Goals): InspirationReport (Clock) or TriggerInput (Human/Event)
|
||||
// - P2 (Tasks): Goals + Available tools
|
||||
// - P3 (Run): Tasks
|
||||
// - P4 (Delivery): Task results
|
||||
// - P5 (Learning): Execution summary
|
||||
type InputFormatter struct{}
|
||||
|
||||
// NewInputFormatter creates a new InputFormatter
|
||||
func NewInputFormatter() *InputFormatter {
|
||||
return &InputFormatter{}
|
||||
}
|
||||
|
||||
// FormatClockContext formats ClockContext as user message content
|
||||
// Used by P0 (Inspiration) phase
|
||||
func (f *InputFormatter) FormatClockContext(clock *types.ClockContext, robot *types.Robot) string {
|
||||
if clock == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Time context section
|
||||
sb.WriteString("## Current Time Context\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Now**: %s\n", clock.Now.Format("2006-01-02 15:04:05")))
|
||||
sb.WriteString(fmt.Sprintf("- **Day**: %s\n", clock.DayOfWeek))
|
||||
sb.WriteString(fmt.Sprintf("- **Date**: %d/%d/%d\n", clock.Year, clock.Month, clock.DayOfMonth))
|
||||
sb.WriteString(fmt.Sprintf("- **Week**: %d of year\n", clock.WeekOfYear))
|
||||
sb.WriteString(fmt.Sprintf("- **Timezone**: %s\n", clock.TZ))
|
||||
|
||||
// Time markers
|
||||
sb.WriteString("\n### Time Markers\n")
|
||||
if clock.IsWeekend {
|
||||
sb.WriteString("- ✓ Weekend\n")
|
||||
}
|
||||
if clock.IsMonthStart {
|
||||
sb.WriteString("- ✓ Month Start (1st-3rd)\n")
|
||||
}
|
||||
if clock.IsMonthEnd {
|
||||
sb.WriteString("- ✓ Month End (last 3 days)\n")
|
||||
}
|
||||
if clock.IsQuarterEnd {
|
||||
sb.WriteString("- ✓ Quarter End\n")
|
||||
}
|
||||
if clock.IsYearEnd {
|
||||
sb.WriteString("- ✓ Year End\n")
|
||||
}
|
||||
|
||||
// Robot identity section (if available)
|
||||
if robot != nil && robot.Config != nil && robot.Config.Identity != nil {
|
||||
sb.WriteString("\n## Robot Identity\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Role**: %s\n", robot.Config.Identity.Role))
|
||||
if len(robot.Config.Identity.Duties) > 0 {
|
||||
sb.WriteString("- **Duties**:\n")
|
||||
for _, duty := range robot.Config.Identity.Duties {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", duty))
|
||||
}
|
||||
}
|
||||
if len(robot.Config.Identity.Rules) > 0 {
|
||||
sb.WriteString("- **Rules**:\n")
|
||||
for _, rule := range robot.Config.Identity.Rules {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", rule))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatInspirationReport formats InspirationReport as user message content
|
||||
// Used by P1 (Goals) phase when trigger is Clock
|
||||
func (f *InputFormatter) FormatInspirationReport(report *types.InspirationReport) string {
|
||||
if report == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Clock context summary (if available)
|
||||
if report.Clock != nil {
|
||||
sb.WriteString("## Time Context\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Time**: %s %s\n", report.Clock.DayOfWeek, report.Clock.Now.Format("15:04")))
|
||||
sb.WriteString(fmt.Sprintf("- **Date**: %d/%d/%d\n", report.Clock.Year, report.Clock.Month, report.Clock.DayOfMonth))
|
||||
|
||||
// Add relevant time markers
|
||||
var markers []string
|
||||
if report.Clock.IsWeekend {
|
||||
markers = append(markers, "Weekend")
|
||||
}
|
||||
if report.Clock.IsMonthStart {
|
||||
markers = append(markers, "Month Start")
|
||||
}
|
||||
if report.Clock.IsMonthEnd {
|
||||
markers = append(markers, "Month End")
|
||||
}
|
||||
if report.Clock.IsQuarterEnd {
|
||||
markers = append(markers, "Quarter End")
|
||||
}
|
||||
if len(markers) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- **Markers**: %s\n", strings.Join(markers, ", ")))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Inspiration content
|
||||
if report.Content != "" {
|
||||
sb.WriteString("## Inspiration Report\n\n")
|
||||
sb.WriteString(report.Content)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatTriggerInput formats TriggerInput as user message content
|
||||
// Used by P1 (Goals) phase when trigger is Human or Event
|
||||
func (f *InputFormatter) FormatTriggerInput(input *types.TriggerInput) string {
|
||||
if input == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Human intervention
|
||||
if input.Action != "" {
|
||||
sb.WriteString("## Human Intervention\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Action**: %s\n", input.Action))
|
||||
if input.UserID != "" {
|
||||
sb.WriteString(fmt.Sprintf("- **User**: %s\n", input.UserID))
|
||||
}
|
||||
|
||||
// Messages
|
||||
if len(input.Messages) > 0 {
|
||||
sb.WriteString("\n### User Input\n\n")
|
||||
for _, msg := range input.Messages {
|
||||
if content, ok := msg.Content.(string); ok {
|
||||
sb.WriteString(content)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Event trigger
|
||||
if input.Source != "" {
|
||||
sb.WriteString("## Event Trigger\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Source**: %s\n", input.Source))
|
||||
sb.WriteString(fmt.Sprintf("- **Event Type**: %s\n", input.EventType))
|
||||
|
||||
// Event data
|
||||
if input.Data != nil {
|
||||
sb.WriteString("\n### Event Data\n\n")
|
||||
sb.WriteString("```json\n")
|
||||
if data, err := json.MarshalIndent(input.Data, "", " "); err == nil {
|
||||
sb.WriteString(string(data))
|
||||
}
|
||||
sb.WriteString("\n```\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// FormatGoals formats Goals as user message content
|
||||
// Used by P2 (Tasks) phase
|
||||
func (f *InputFormatter) FormatGoals(goals *types.Goals, robot *types.Robot) string {
|
||||
if goals == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Goals content
|
||||
sb.WriteString("## Goals\n\n")
|
||||
sb.WriteString(goals.Content)
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Available resources (if robot config available)
|
||||
if robot != nil && robot.Config != nil && robot.Config.Resources != nil {
|
||||
sb.WriteString("\n## Available Resources\n\n")
|
||||
|
||||
// Agents
|
||||
if len(robot.Config.Resources.Agents) > 0 {
|
||||
sb.WriteString("### Agents\n")
|
||||
for _, agent := range robot.Config.Resources.Agents {
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", agent))
|
||||
}
|
||||
}
|
||||
|
||||
// MCP tools
|
||||
if len(robot.Config.Resources.MCP) > 0 {
|
||||
sb.WriteString("\n### MCP Tools\n")
|
||||
for _, mcp := range robot.Config.Resources.MCP {
|
||||
if len(mcp.Tools) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n", mcp.ID, strings.Join(mcp.Tools, ", ")))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- %s: all tools\n", mcp.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatTasks formats Tasks as user message content
|
||||
// Used by P3 (Run) phase
|
||||
func (f *InputFormatter) FormatTasks(tasks []types.Task) string {
|
||||
if len(tasks) == 0 {
|
||||
return "No tasks to execute."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Tasks to Execute\n\n")
|
||||
for i, task := range tasks {
|
||||
sb.WriteString(fmt.Sprintf("### Task %d: %s\n\n", i+1, task.ID))
|
||||
sb.WriteString(fmt.Sprintf("- **Goal Reference**: %s\n", task.GoalRef))
|
||||
sb.WriteString(fmt.Sprintf("- **Source**: %s\n", task.Source))
|
||||
sb.WriteString(fmt.Sprintf("- **Executor**: %s (%s)\n", task.ExecutorID, task.ExecutorType))
|
||||
|
||||
// Task content
|
||||
if len(task.Messages) > 0 {
|
||||
sb.WriteString("\n**Instructions**:\n")
|
||||
for _, msg := range task.Messages {
|
||||
if content, ok := msg.Content.(string); ok {
|
||||
sb.WriteString(content)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arguments
|
||||
if len(task.Args) > 0 {
|
||||
sb.WriteString("\n**Arguments**:\n")
|
||||
if args, err := json.MarshalIndent(task.Args, "", " "); err == nil {
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(string(args))
|
||||
sb.WriteString("\n```\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatTaskResults formats TaskResults as user message content
|
||||
// Used by P4 (Delivery) and P5 (Learning) phases
|
||||
func (f *InputFormatter) FormatTaskResults(results []types.TaskResult) string {
|
||||
if len(results) == 0 {
|
||||
return "No task results."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Task Results\n\n")
|
||||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
|
||||
for _, result := range results {
|
||||
if result.Success {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### Task: %s\n\n", result.TaskID))
|
||||
if result.Success {
|
||||
sb.WriteString("- **Status**: ✓ Success\n")
|
||||
} else {
|
||||
sb.WriteString("- **Status**: ✗ Failed\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- **Duration**: %dms\n", result.Duration))
|
||||
sb.WriteString(fmt.Sprintf("- **Validated**: %t\n", result.Validated))
|
||||
|
||||
// Output
|
||||
if result.Output != nil {
|
||||
sb.WriteString("\n**Output**:\n")
|
||||
if output, err := json.MarshalIndent(result.Output, "", " "); err == nil {
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(string(output))
|
||||
sb.WriteString("\n```\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%v\n", result.Output))
|
||||
}
|
||||
}
|
||||
|
||||
// Error
|
||||
if result.Error != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Error**: %s\n", result.Error))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Summary
|
||||
sb.WriteString(fmt.Sprintf("## Summary\n\n- Total: %d tasks\n- Success: %d\n- Failed: %d\n",
|
||||
len(results), successCount, failCount))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatExecutionSummary formats the entire execution for P5 (Learning) phase
|
||||
func (f *InputFormatter) FormatExecutionSummary(exec *types.Execution) string {
|
||||
if exec == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Execution metadata
|
||||
sb.WriteString("## Execution Summary\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **ID**: %s\n", exec.ID))
|
||||
sb.WriteString(fmt.Sprintf("- **Trigger**: %s\n", exec.TriggerType))
|
||||
sb.WriteString(fmt.Sprintf("- **Status**: %s\n", exec.Status))
|
||||
sb.WriteString(fmt.Sprintf("- **Start Time**: %s\n", exec.StartTime.Format("2006-01-02 15:04:05")))
|
||||
if exec.EndTime != nil {
|
||||
sb.WriteString(fmt.Sprintf("- **End Time**: %s\n", exec.EndTime.Format("2006-01-02 15:04:05")))
|
||||
duration := exec.EndTime.Sub(exec.StartTime)
|
||||
sb.WriteString(fmt.Sprintf("- **Duration**: %s\n", duration.String()))
|
||||
}
|
||||
if exec.Error != "" {
|
||||
sb.WriteString(fmt.Sprintf("- **Error**: %s\n", exec.Error))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Inspiration (P0)
|
||||
if exec.Inspiration != nil && exec.Inspiration.Content != "" {
|
||||
sb.WriteString("## Inspiration (P0)\n\n")
|
||||
sb.WriteString(exec.Inspiration.Content)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Goals (P1)
|
||||
if exec.Goals != nil && exec.Goals.Content != "" {
|
||||
sb.WriteString("## Goals (P1)\n\n")
|
||||
sb.WriteString(exec.Goals.Content)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Tasks (P2)
|
||||
if len(exec.Tasks) > 0 {
|
||||
sb.WriteString("## Tasks (P2)\n\n")
|
||||
for i, task := range exec.Tasks {
|
||||
sb.WriteString(fmt.Sprintf("%d. [%s] %s (executor: %s)\n",
|
||||
i+1, task.Status, task.ID, task.ExecutorID))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Results (P3)
|
||||
if len(exec.Results) > 0 {
|
||||
sb.WriteString("## Results (P3)\n\n")
|
||||
for _, result := range exec.Results {
|
||||
status := "✓"
|
||||
if !result.Success {
|
||||
status = "✗"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- %s %s (%dms)\n", status, result.TaskID, result.Duration))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Delivery (P4)
|
||||
if exec.Delivery != nil {
|
||||
sb.WriteString("## Delivery (P4)\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Type**: %s\n", exec.Delivery.Type))
|
||||
if exec.Delivery.Success {
|
||||
sb.WriteString("- **Status**: ✓ Success\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- **Status**: ✗ Failed (%s)\n", exec.Delivery.Error))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// BuildMessages is a convenience method to build messages array from content
|
||||
func (f *InputFormatter) BuildMessages(userContent string) []agentcontext.Message {
|
||||
return []agentcontext.Message{
|
||||
{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: userContent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildMessagesWithSystem builds messages array with system and user content
|
||||
func (f *InputFormatter) BuildMessagesWithSystem(systemContent, userContent string) []agentcontext.Message {
|
||||
return []agentcontext.Message{
|
||||
{
|
||||
Role: agentcontext.RoleSystem,
|
||||
Content: systemContent,
|
||||
},
|
||||
{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: userContent,
|
||||
},
|
||||
}
|
||||
}
|
||||
423
agent/robot/executor/input_test.go
Normal file
423
agent/robot/executor/input_test.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
package executor_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/robot/executor"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// InputFormatter Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestInputFormatterFormatClockContext(t *testing.T) {
|
||||
formatter := executor.NewInputFormatter()
|
||||
|
||||
t.Run("formats clock context with all fields", func(t *testing.T) {
|
||||
now := time.Date(2024, 1, 15, 9, 30, 0, 0, time.UTC)
|
||||
clock := types.NewClockContext(now, "UTC")
|
||||
|
||||
result := formatter.FormatClockContext(clock, nil)
|
||||
|
||||
assert.Contains(t, result, "## Current Time Context")
|
||||
assert.Contains(t, result, "2024-01-15 09:30:00")
|
||||
assert.Contains(t, result, "Monday")
|
||||
assert.Contains(t, result, "UTC")
|
||||
assert.Contains(t, result, "### Time Markers")
|
||||
})
|
||||
|
||||
t.Run("includes robot identity when provided", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
clock := types.NewClockContext(now, "UTC")
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-robot",
|
||||
Config: &types.Config{
|
||||
Identity: &types.Identity{
|
||||
Role: "Sales Analyst",
|
||||
Duties: []string{"Analyze sales data", "Generate reports"},
|
||||
Rules: []string{"Be accurate", "Be concise"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatClockContext(clock, robot)
|
||||
|
||||
assert.Contains(t, result, "## Robot Identity")
|
||||
assert.Contains(t, result, "Sales Analyst")
|
||||
assert.Contains(t, result, "Analyze sales data")
|
||||
assert.Contains(t, result, "Be accurate")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil clock", func(t *testing.T) {
|
||||
result := formatter.FormatClockContext(nil, nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("marks weekend correctly", func(t *testing.T) {
|
||||
// Saturday
|
||||
saturday := time.Date(2024, 1, 13, 10, 0, 0, 0, time.UTC)
|
||||
clock := types.NewClockContext(saturday, "UTC")
|
||||
|
||||
result := formatter.FormatClockContext(clock, nil)
|
||||
|
||||
assert.Contains(t, result, "✓ Weekend")
|
||||
})
|
||||
|
||||
t.Run("marks month start correctly", func(t *testing.T) {
|
||||
// 2nd of month
|
||||
monthStart := time.Date(2024, 1, 2, 10, 0, 0, 0, time.UTC)
|
||||
clock := types.NewClockContext(monthStart, "UTC")
|
||||
|
||||
result := formatter.FormatClockContext(clock, nil)
|
||||
|
||||
assert.Contains(t, result, "✓ Month Start")
|
||||
})
|
||||
|
||||
t.Run("marks month end correctly", func(t *testing.T) {
|
||||
// 30th of January (last 3 days)
|
||||
monthEnd := time.Date(2024, 1, 30, 10, 0, 0, 0, time.UTC)
|
||||
clock := types.NewClockContext(monthEnd, "UTC")
|
||||
|
||||
result := formatter.FormatClockContext(clock, nil)
|
||||
|
||||
assert.Contains(t, result, "✓ Month End")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatInspirationReport(t *testing.T) {
|
||||
formatter := executor.NewInputFormatter()
|
||||
|
||||
t.Run("formats inspiration report with clock", func(t *testing.T) {
|
||||
now := time.Date(2024, 1, 15, 9, 30, 0, 0, time.UTC)
|
||||
clock := types.NewClockContext(now, "UTC")
|
||||
report := &types.InspirationReport{
|
||||
Clock: clock,
|
||||
Content: "Today is a good day to analyze sales data.",
|
||||
}
|
||||
|
||||
result := formatter.FormatInspirationReport(report)
|
||||
|
||||
assert.Contains(t, result, "## Time Context")
|
||||
assert.Contains(t, result, "Monday")
|
||||
assert.Contains(t, result, "## Inspiration Report")
|
||||
assert.Contains(t, result, "analyze sales data")
|
||||
})
|
||||
|
||||
t.Run("formats inspiration report without clock", func(t *testing.T) {
|
||||
report := &types.InspirationReport{
|
||||
Content: "Focus on quarterly review.",
|
||||
}
|
||||
|
||||
result := formatter.FormatInspirationReport(report)
|
||||
|
||||
assert.NotContains(t, result, "## Time Context")
|
||||
assert.Contains(t, result, "## Inspiration Report")
|
||||
assert.Contains(t, result, "quarterly review")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil report", func(t *testing.T) {
|
||||
result := formatter.FormatInspirationReport(nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatTriggerInput(t *testing.T) {
|
||||
formatter := executor.NewInputFormatter()
|
||||
|
||||
t.Run("formats human intervention", func(t *testing.T) {
|
||||
input := &types.TriggerInput{
|
||||
Action: "task.add",
|
||||
UserID: "user-123",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Please add a task to review Q4 sales"},
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatTriggerInput(input)
|
||||
|
||||
assert.Contains(t, result, "## Human Intervention")
|
||||
assert.Contains(t, result, "task.add")
|
||||
assert.Contains(t, result, "user-123")
|
||||
assert.Contains(t, result, "### User Input")
|
||||
assert.Contains(t, result, "review Q4 sales")
|
||||
})
|
||||
|
||||
t.Run("formats event trigger", func(t *testing.T) {
|
||||
input := &types.TriggerInput{
|
||||
Source: "webhook",
|
||||
EventType: "order.created",
|
||||
Data: map[string]interface{}{
|
||||
"order_id": "12345",
|
||||
"amount": 99.99,
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatTriggerInput(input)
|
||||
|
||||
assert.Contains(t, result, "## Event Trigger")
|
||||
assert.Contains(t, result, "webhook")
|
||||
assert.Contains(t, result, "order.created")
|
||||
assert.Contains(t, result, "### Event Data")
|
||||
assert.Contains(t, result, "order_id")
|
||||
assert.Contains(t, result, "12345")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil input", func(t *testing.T) {
|
||||
result := formatter.FormatTriggerInput(nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("returns empty for empty input", func(t *testing.T) {
|
||||
input := &types.TriggerInput{}
|
||||
result := formatter.FormatTriggerInput(input)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatGoals(t *testing.T) {
|
||||
formatter := executor.NewInputFormatter()
|
||||
|
||||
t.Run("formats goals with resources", func(t *testing.T) {
|
||||
goals := &types.Goals{
|
||||
Content: "1. Analyze sales data\n2. Generate report\n3. Send to stakeholders",
|
||||
}
|
||||
robot := &types.Robot{
|
||||
MemberID: "test-robot",
|
||||
Config: &types.Config{
|
||||
Resources: &types.Resources{
|
||||
Agents: []string{"data-analyzer", "report-generator"},
|
||||
MCP: []types.MCPConfig{
|
||||
{ID: "database", Tools: []string{"query", "insert"}},
|
||||
{ID: "email"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatGoals(goals, robot)
|
||||
|
||||
assert.Contains(t, result, "## Goals")
|
||||
assert.Contains(t, result, "Analyze sales data")
|
||||
assert.Contains(t, result, "## Available Resources")
|
||||
assert.Contains(t, result, "### Agents")
|
||||
assert.Contains(t, result, "data-analyzer")
|
||||
assert.Contains(t, result, "### MCP Tools")
|
||||
assert.Contains(t, result, "database: query, insert")
|
||||
assert.Contains(t, result, "email: all tools")
|
||||
})
|
||||
|
||||
t.Run("formats goals without robot", func(t *testing.T) {
|
||||
goals := &types.Goals{
|
||||
Content: "Complete the task.",
|
||||
}
|
||||
|
||||
result := formatter.FormatGoals(goals, nil)
|
||||
|
||||
assert.Contains(t, result, "## Goals")
|
||||
assert.Contains(t, result, "Complete the task")
|
||||
assert.NotContains(t, result, "## Available Resources")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil goals", func(t *testing.T) {
|
||||
result := formatter.FormatGoals(nil, nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatTasks(t *testing.T) {
|
||||
formatter := executor.NewInputFormatter()
|
||||
|
||||
t.Run("formats multiple tasks", func(t *testing.T) {
|
||||
tasks := []types.Task{
|
||||
{
|
||||
ID: "task-1",
|
||||
GoalRef: "goal-1",
|
||||
Source: types.TaskSourceAuto,
|
||||
ExecutorType: types.ExecutorMCP,
|
||||
ExecutorID: "database.query",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Query sales data for Q4"},
|
||||
},
|
||||
Args: []any{"sales", "Q4"},
|
||||
},
|
||||
{
|
||||
ID: "task-2",
|
||||
GoalRef: "goal-1",
|
||||
Source: types.TaskSourceAuto,
|
||||
ExecutorType: types.ExecutorAssistant,
|
||||
ExecutorID: "report-generator",
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatTasks(tasks)
|
||||
|
||||
assert.Contains(t, result, "## Tasks to Execute")
|
||||
assert.Contains(t, result, "### Task 1: task-1")
|
||||
assert.Contains(t, result, "goal-1")
|
||||
assert.Contains(t, result, "database.query")
|
||||
assert.Contains(t, result, "**Instructions**")
|
||||
assert.Contains(t, result, "Query sales data")
|
||||
assert.Contains(t, result, "**Arguments**")
|
||||
assert.Contains(t, result, "### Task 2: task-2")
|
||||
assert.Contains(t, result, "report-generator")
|
||||
})
|
||||
|
||||
t.Run("returns message for empty tasks", func(t *testing.T) {
|
||||
result := formatter.FormatTasks(nil)
|
||||
assert.Equal(t, "No tasks to execute.", result)
|
||||
|
||||
result = formatter.FormatTasks([]types.Task{})
|
||||
assert.Equal(t, "No tasks to execute.", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatTaskResults(t *testing.T) {
|
||||
formatter := executor.NewInputFormatter()
|
||||
|
||||
t.Run("formats task results with summary", func(t *testing.T) {
|
||||
results := []types.TaskResult{
|
||||
{
|
||||
TaskID: "task-1",
|
||||
Success: true,
|
||||
Duration: 150,
|
||||
Validated: true,
|
||||
Output: map[string]interface{}{"rows": 100},
|
||||
},
|
||||
{
|
||||
TaskID: "task-2",
|
||||
Success: false,
|
||||
Duration: 50,
|
||||
Validated: false,
|
||||
Error: "Connection timeout",
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatTaskResults(results)
|
||||
|
||||
assert.Contains(t, result, "## Task Results")
|
||||
assert.Contains(t, result, "### Task: task-1")
|
||||
assert.Contains(t, result, "✓ Success")
|
||||
assert.Contains(t, result, "150ms")
|
||||
assert.Contains(t, result, "**Output**")
|
||||
assert.Contains(t, result, "### Task: task-2")
|
||||
assert.Contains(t, result, "✗ Failed")
|
||||
assert.Contains(t, result, "Connection timeout")
|
||||
assert.Contains(t, result, "## Summary")
|
||||
assert.Contains(t, result, "Total: 2 tasks")
|
||||
assert.Contains(t, result, "Success: 1")
|
||||
assert.Contains(t, result, "Failed: 1")
|
||||
})
|
||||
|
||||
t.Run("returns message for empty results", func(t *testing.T) {
|
||||
result := formatter.FormatTaskResults(nil)
|
||||
assert.Equal(t, "No task results.", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterFormatExecutionSummary(t *testing.T) {
|
||||
formatter := executor.NewInputFormatter()
|
||||
|
||||
t.Run("formats complete execution summary", func(t *testing.T) {
|
||||
startTime := time.Date(2024, 1, 15, 9, 0, 0, 0, time.UTC)
|
||||
endTime := time.Date(2024, 1, 15, 9, 5, 0, 0, time.UTC)
|
||||
exec := &types.Execution{
|
||||
ID: "exec-123",
|
||||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecCompleted,
|
||||
StartTime: startTime,
|
||||
EndTime: &endTime,
|
||||
Inspiration: &types.InspirationReport{
|
||||
Content: "Morning analysis suggests high activity.",
|
||||
},
|
||||
Goals: &types.Goals{
|
||||
Content: "1. Review data\n2. Generate report",
|
||||
},
|
||||
Tasks: []types.Task{
|
||||
{ID: "t1", Status: types.TaskCompleted, ExecutorID: "db.query"},
|
||||
{ID: "t2", Status: types.TaskCompleted, ExecutorID: "report.gen"},
|
||||
},
|
||||
Results: []types.TaskResult{
|
||||
{TaskID: "t1", Success: true, Duration: 100},
|
||||
{TaskID: "t2", Success: true, Duration: 200},
|
||||
},
|
||||
Delivery: &types.DeliveryResult{
|
||||
Type: types.DeliveryEmail,
|
||||
Success: true,
|
||||
},
|
||||
}
|
||||
|
||||
result := formatter.FormatExecutionSummary(exec)
|
||||
|
||||
assert.Contains(t, result, "## Execution Summary")
|
||||
assert.Contains(t, result, "exec-123")
|
||||
assert.Contains(t, result, "clock")
|
||||
assert.Contains(t, result, "completed")
|
||||
assert.Contains(t, result, "**Duration**:")
|
||||
assert.Contains(t, result, "## Inspiration (P0)")
|
||||
assert.Contains(t, result, "Morning analysis")
|
||||
assert.Contains(t, result, "## Goals (P1)")
|
||||
assert.Contains(t, result, "Review data")
|
||||
assert.Contains(t, result, "## Tasks (P2)")
|
||||
assert.Contains(t, result, "db.query")
|
||||
assert.Contains(t, result, "## Results (P3)")
|
||||
assert.Contains(t, result, "✓ t1")
|
||||
assert.Contains(t, result, "## Delivery (P4)")
|
||||
assert.Contains(t, result, "email")
|
||||
})
|
||||
|
||||
t.Run("formats execution with error", func(t *testing.T) {
|
||||
startTime := time.Now()
|
||||
exec := &types.Execution{
|
||||
ID: "exec-456",
|
||||
TriggerType: types.TriggerHuman,
|
||||
Status: types.ExecFailed,
|
||||
StartTime: startTime,
|
||||
Error: "Task execution failed",
|
||||
}
|
||||
|
||||
result := formatter.FormatExecutionSummary(exec)
|
||||
|
||||
assert.Contains(t, result, "exec-456")
|
||||
assert.Contains(t, result, "failed")
|
||||
assert.Contains(t, result, "**Error**: Task execution failed")
|
||||
})
|
||||
|
||||
t.Run("returns empty for nil execution", func(t *testing.T) {
|
||||
result := formatter.FormatExecutionSummary(nil)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterBuildMessages(t *testing.T) {
|
||||
formatter := executor.NewInputFormatter()
|
||||
|
||||
t.Run("builds user message", func(t *testing.T) {
|
||||
msgs := formatter.BuildMessages("Hello, world!")
|
||||
|
||||
require.Len(t, msgs, 1)
|
||||
assert.Equal(t, agentcontext.RoleUser, msgs[0].Role)
|
||||
assert.Equal(t, "Hello, world!", msgs[0].Content)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInputFormatterBuildMessagesWithSystem(t *testing.T) {
|
||||
formatter := executor.NewInputFormatter()
|
||||
|
||||
t.Run("builds system and user messages", func(t *testing.T) {
|
||||
msgs := formatter.BuildMessagesWithSystem(
|
||||
"You are a helpful assistant.",
|
||||
"What is the weather?",
|
||||
)
|
||||
|
||||
require.Len(t, msgs, 2)
|
||||
assert.Equal(t, agentcontext.RoleSystem, msgs[0].Role)
|
||||
assert.Equal(t, "You are a helpful assistant.", msgs[0].Content)
|
||||
assert.Equal(t, agentcontext.RoleUser, msgs[1].Role)
|
||||
assert.Equal(t, "What is the weather?", msgs[1].Content)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue