From bcb04f867731ca60f65623f5bf606703df827902 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 16 Jan 2026 09:52:57 +0800 Subject: [PATCH] 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. --- agent/robot/TODO.md | 312 +++++++++++---- agent/robot/executor/agent.go | 475 +++++++++++++++++++++++ agent/robot/executor/agent_test.go | 585 +++++++++++++++++++++++++++++ agent/robot/executor/input.go | 417 ++++++++++++++++++++ agent/robot/executor/input_test.go | 423 +++++++++++++++++++++ 5 files changed, 2136 insertions(+), 76 deletions(-) create mode 100644 agent/robot/executor/agent.go create mode 100644 agent/robot/executor/agent_test.go create mode 100644 agent/robot/executor/input.go create mode 100644 agent/robot/executor/input_test.go diff --git a/agent/robot/TODO.md b/agent/robot/TODO.md index 0d238ebc..d4d8de4b 100644 --- a/agent/robot/TODO.md +++ b/agent/robot/TODO.md @@ -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 diff --git a/agent/robot/executor/agent.go b/agent/robot/executor/agent.go new file mode 100644 index 00000000..db729bde --- /dev/null +++ b/agent/robot/executor/agent.go @@ -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 + } + } +} diff --git a/agent/robot/executor/agent_test.go b/agent/robot/executor/agent_test.go new file mode 100644 index 00000000..f97ca447 --- /dev/null +++ b/agent/robot/executor/agent_test.go @@ -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) + }) +} diff --git a/agent/robot/executor/input.go b/agent/robot/executor/input.go new file mode 100644 index 00000000..541944ac --- /dev/null +++ b/agent/robot/executor/input.go @@ -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, + }, + } +} diff --git a/agent/robot/executor/input_test.go b/agent/robot/executor/input_test.go new file mode 100644 index 00000000..2fe7ce80 --- /dev/null +++ b/agent/robot/executor/input_test.go @@ -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) + }) +}