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:
Max 2026-01-16 09:52:57 +08:00
parent 00851c442e
commit bcb04f8677
5 changed files with 2136 additions and 76 deletions

View file

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

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

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

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

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