diff --git a/agent/robot/DESIGN.md b/agent/robot/DESIGN.md index adb22164..431307f2 100644 --- a/agent/robot/DESIGN.md +++ b/agent/robot/DESIGN.md @@ -138,6 +138,7 @@ Uses existing `__yao.member` model (`yao/models/member.mod.yao`): | `robot_config` | JSON | Agent configuration (see section 5) | | `robot_status` | enum | `idle` \| `working` \| `paused` \| `error` \| `maintenance` | | `system_prompt` | text | Identity & role prompt | +| `robot_email` | string | Robot's email address for sending emails (From address) | | `agents` | JSON | Accessible agents list | | `mcp_servers` | JSON | Accessible MCP servers | | `manager_id` | string | Direct manager user ID | @@ -262,7 +263,7 @@ Human/Event: P1 → P2 → P3 → P4 → P5 | P1 | Goal Gen | Report + history | Goals | Always | | P2 | Task Plan | Goals + tools | Tasks | Always | | P3 | Run + Valid | Tasks + Experts | TaskResults | Always | -| P4 | Delivery | All results | Email/File | Always | +| P4 | Delivery | All results | Email/Webhook/Process | Always | | P5 | Learning | Summary | KB entries | Always | ### 4.2 P0: Inspiration (Clock only) @@ -316,7 +317,7 @@ type Goals struct { } type DeliveryTarget struct { - Type DeliveryType // email | webhook | report | notification + Type DeliveryType // Preferred delivery type (P4 will use Delivery Center) Recipients []string // email addresses, webhook URLs, user IDs Format string // markdown | html | json | text Template string // template name @@ -489,15 +490,197 @@ Universal assertion library supporting 8 types: ### 4.6 P4: Deliver -Send output: +P4 generates delivery content and pushes to Delivery Center. **Agent only generates content, Delivery Center decides channels.** + +**Architecture:** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ P4 Delivery Agent │ +│ Role: Generate content only (Summary, Body, Attachments) │ +│ NOT responsible for: Channel selection │ +└─────────────────────┬───────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DeliveryRequest │ +│ - Content: Summary, Body, Attachments │ +│ - Context: member_id, execution_id, trigger, team │ +│ (No Channels - Delivery Center decides) │ +└─────────────────────┬───────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Delivery Center │ +│ Role: │ +│ 1. Read Robot/User delivery preferences │ +│ 2. Decide which channels to use │ +│ 3. Execute delivery (email, webhook, process) │ +│ 4. Future: auto-notify based on user subscriptions │ +│ │ +│ (Current: internal, future: yao/delivery) │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key Design:** +- **Separation of concerns**: Agent generates content, Delivery Center handles channels +- **User preferences**: Channels decided by Robot/User configuration, not Agent +- **Automatic delivery**: If webhook configured, every execution pushes automatically +- **Future-ready**: Delivery Center can be extracted to `yao/delivery` package + +**Delivery Request Structure:** + +```go +// DeliveryRequest - pushed to Delivery Center +// No Channels field - Delivery Center decides based on preferences +type DeliveryRequest struct { + Content *DeliveryContent `json:"content"` // Agent-generated content + Context *DeliveryContext `json:"context"` // Tracking info +} + +// DeliveryContent - content generated by Delivery Agent +type DeliveryContent struct { + Summary string `json:"summary"` // Brief 1-2 sentence summary + Body string `json:"body"` // Full markdown report + Attachments []DeliveryAttachment `json:"attachments,omitempty"` // Output artifacts +} + +// DeliveryAttachment - task output attachment with metadata +type DeliveryAttachment struct { + Title string `json:"title"` // Human-readable title + Description string `json:"description,omitempty"` // What this artifact is + TaskID string `json:"task_id,omitempty"` // Which task produced this + File string `json:"file"` // Wrapper: __:// +} + +// DeliveryContext - tracking and audit info +type DeliveryContext struct { + MemberID string `json:"member_id"` // Robot member ID (globally unique) + ExecutionID string `json:"execution_id"` + TriggerType TriggerType `json:"trigger_type"` + TeamID string `json:"team_id"` +} +``` + +**File Wrapper Format:** + +Attachments use the standard `yao/attachment` wrapper format: +- Format: `__://` +- Example: `__yao.attachment://ccd472d11feb96e03a3fc468f494045c` +- Parse: `attachment.Parse(value)` → `(uploader, fileID, isWrapper)` +- Read: `attachment.Base64(ctx, value)` → base64 content + +**Delivery Channels (Delivery Center decides):** + +| Channel | Description | Multiple Targets | +|---------|-------------|------------------| +| `email` | Send via yao/messenger | ✅ Multiple recipients/emails | +| `webhook` | POST to external URL | ✅ Multiple URLs | +| `process` | Yao Process call | ✅ Multiple processes | +| `notify` | In-app notification | Future (auto by subscriptions) | + +**Delivery Agent:** + +The Delivery Agent **only generates content**, does NOT decide channels: + +```go +// Delivery Agent Input +type DeliveryAgentInput struct { + Robot *Robot `json:"robot"` + TriggerType TriggerType `json:"trigger"` + Inspiration *InspirationReport `json:"inspiration"` // P0 + Goals *Goals `json:"goals"` // P1 + Tasks []Task `json:"tasks"` // P2 + Results []TaskResult `json:"results"` // P3 +} + +// Delivery Agent Output - only content, no channels +type DeliveryAgentOutput struct { + Content *DeliveryContent `json:"content"` +} +``` + +**Example Agent Output:** + +```json +{ + "content": { + "summary": "Sales report completed: 15 new leads processed", + "body": "## Weekly Sales Report\n\n### Summary\n...", + "attachments": [ + {"title": "Sales Report.pdf", "file": "__yao.attachment://abc123"}, + {"title": "Lead Analysis.xlsx", "file": "__yao.attachment://def456"} + ] + } +} +``` + +**Delivery Result:** + +```go +// DeliveryResult - returned by Delivery Center +type DeliveryResult struct { + RequestID string `json:"request_id"` // Delivery request ID + Content *DeliveryContent `json:"content"` // Agent-generated content + Results []ChannelResult `json:"results,omitempty"` // Results per channel + Success bool `json:"success"` // Overall success + Error string `json:"error,omitempty"` // Error if failed + SentAt *time.Time `json:"sent_at,omitempty"` // When delivery completed +} + +// ChannelResult - result for a single delivery target +type ChannelResult struct { + Type DeliveryType `json:"type"` // email | webhook | process + Target string `json:"target"` // Target identifier (email, URL, process name) + Success bool `json:"success"` // Whether delivery succeeded + Recipients []string `json:"recipients,omitempty"` // Who received (for email) + Details interface{} `json:"details,omitempty"` // Channel-specific response + Error string `json:"error,omitempty"` // Error message if failed + SentAt *time.Time `json:"sent_at,omitempty"` // When this target was delivered +} +``` + +**Config (Delivery Preferences):** + +Robot config defines delivery **preferences** (Delivery Center reads and executes). +Each channel supports **multiple targets**: ```yaml delivery: - type: email # email | file | webhook | notify - opts: - to: ["manager@company.com"] + preferences: + email: + enabled: true + targets: # Multiple email targets + - to: ["manager@company.com"] + cc: ["team@company.com"] + - to: ["ceo@company.com"] + subject_template: "Executive Summary" + + webhook: + enabled: true + targets: # Multiple webhook URLs + - url: "https://slack.com/webhook/sales" + - url: "https://feishu.cn/webhook/reports" + headers: {"X-Custom": "value"} + + process: + enabled: true + targets: # Multiple Yao Process calls + - name: "orders.UpdateStatus" + args: ["completed"] + - name: "audit.LogDelivery" + +# Note: notify handled by Delivery Center based on user subscriptions (future) ``` +**Use Cases:** + +| Scenario | Channels | Description | +|----------|----------|-------------| +| Event callback | `process` | DB change → Robot → Update data via Process | +| Multi-channel notify | `email` + `webhook` | Send to multiple emails and Slack/飞书 | +| Data pipeline | `process` | Robot result → Save to DB → Update dashboard | + ### 4.7 P5: Learn Save to KB: @@ -524,7 +707,7 @@ type Config struct { DB *DB `json:"db,omitempty"` // shared DB (same as assistant) Learn *Learn `json:"learn,omitempty"` // learning for private KB Resources *Resources `json:"resources"` - Delivery *Delivery `json:"delivery"` + Delivery *DeliveryPreferences `json:"delivery,omitempty"` Events []Event `json:"events,omitempty"` Executor *Executor `json:"executor,omitempty"` // executor mode settings } @@ -564,10 +747,10 @@ const ( type DeliveryType string const ( - DeliveryEmail DeliveryType = "email" - DeliveryFile DeliveryType = "file" - DeliveryWebhook DeliveryType = "webhook" - DeliveryNotify DeliveryType = "notify" + DeliveryEmail DeliveryType = "email" // Email via yao/messenger + DeliveryWebhook DeliveryType = "webhook" // POST to external URL + DeliveryProcess DeliveryType = "process" // Yao Process call + DeliveryNotify DeliveryType = "notify" // In-app notification (future) ) // ExecStatus - execution status enum @@ -578,6 +761,7 @@ const ( ExecRunning ExecStatus = "running" ExecCompleted ExecStatus = "completed" ExecFailed ExecStatus = "failed" + ExecCancelled ExecStatus = "cancelled" ) // RobotStatus - matches __yao.member.robot_status enum @@ -660,10 +844,46 @@ type MCP struct { Tools []string `json:"tools,omitempty"` // empty = all } -// Delivery -type Delivery struct { - Type DeliveryType `json:"type"` - Opts map[string]interface{} `json:"opts"` +// DeliveryPreferences - Robot delivery preferences (read by Delivery Center) +// Each channel supports multiple targets +type DeliveryPreferences struct { + Email *EmailPreference `json:"email,omitempty"` + Webhook *WebhookPreference `json:"webhook,omitempty"` + Process *ProcessPreference `json:"process,omitempty"` + // notify is handled automatically based on user subscriptions +} + +type EmailPreference struct { + Enabled bool `json:"enabled"` + Targets []EmailTarget `json:"targets"` +} + +type EmailTarget struct { + To []string `json:"to"` // Recipient addresses + Template string `json:"template,omitempty"` // Email template ID + Subject string `json:"subject,omitempty"` // Subject template +} + +type WebhookPreference struct { + Enabled bool `json:"enabled"` + Targets []WebhookTarget `json:"targets"` +} + +type WebhookTarget struct { + URL string `json:"url"` // Webhook URL + Method string `json:"method,omitempty"` // HTTP method (default: POST) + Headers map[string]string `json:"headers,omitempty"` // Custom headers + Secret string `json:"secret,omitempty"` // Signing secret +} + +type ProcessPreference struct { + Enabled bool `json:"enabled"` + Targets []ProcessTarget `json:"targets"` +} + +type ProcessTarget struct { + Process string `json:"process"` // Yao Process name, e.g., "orders.UpdateStatus" + Args []any `json:"args,omitempty"` // Additional arguments } // ExecutorMode - executor mode enum diff --git a/agent/robot/TECHNICAL.md b/agent/robot/TECHNICAL.md index 4c42fe49..4578e2c5 100644 --- a/agent/robot/TECHNICAL.md +++ b/agent/robot/TECHNICAL.md @@ -854,10 +854,10 @@ const ( type DeliveryType string const ( - DeliveryEmail DeliveryType = "email" - DeliveryFile DeliveryType = "file" - DeliveryWebhook DeliveryType = "webhook" - DeliveryNotify DeliveryType = "notify" + DeliveryEmail DeliveryType = "email" // Email via yao/messenger + DeliveryWebhook DeliveryType = "webhook" // POST to external URL + DeliveryProcess DeliveryType = "process" // Yao Process call + DeliveryNotify DeliveryType = "notify" // In-app notification (future) ) // DedupResult - deduplication result @@ -954,7 +954,7 @@ type Config struct { DB *DB `json:"db,omitempty"` // shared database (same as assistant) Learn *Learn `json:"learn,omitempty"` // learning config for private KB Resources *Resources `json:"resources,omitempty"` - Delivery *Delivery `json:"delivery,omitempty"` + Delivery *DeliveryPreferences `json:"delivery,omitempty"` // see section 6.2 Events []Event `json:"events,omitempty"` } @@ -1134,11 +1134,7 @@ type MCPConfig struct { Tools []string `json:"tools,omitempty"` // empty = all } -// Delivery - output delivery -type Delivery struct { - Type DeliveryType `json:"type"` - Opts map[string]interface{} `json:"opts,omitempty"` -} +// Note: Delivery preferences moved to DeliveryPreferences (see section 6.2) // Event - event trigger config type Event struct { @@ -1173,6 +1169,7 @@ type Robot struct { SystemPrompt string `json:"system_prompt"` Status RobotStatus `json:"robot_status"` AutonomousMode bool `json:"autonomous_mode"` + RobotEmail string `json:"robot_email"` // Robot's email address for sending emails // Parsed config (from robot_config JSON field) Config *Config `json:"-"` @@ -1241,7 +1238,7 @@ func (r *Robot) GetExecutions() []*Execution { // Relationship: 1 Execution = 1 job.Job type Execution struct { ID string `json:"id"` // unique execution ID - MemberID string `json:"member_id"` // robot member ID + MemberID string `json:"member_id"` // robot member ID (globally unique) TeamID string `json:"team_id"` TriggerType TriggerType `json:"trigger_type"` // clock | human | event StartTime time.Time `json:"start_time"` @@ -1309,9 +1306,11 @@ type Goals struct { Delivery *DeliveryTarget `json:"delivery,omitempty"` // where to send results (for P4) } -// DeliveryTarget - where to deliver results (defined in P1, used in P4) +// DeliveryTarget - where to deliver results (defined in P1, used by P4) +// Note: This is a hint from P1 Goals. Actual delivery is handled by Delivery Center +// based on Robot/User preferences, not strictly by this target. type DeliveryTarget struct { - Type DeliveryType `json:"type"` // email | webhook | report | notification + Type DeliveryType `json:"type"` // Preferred delivery type Recipients []string `json:"recipients,omitempty"` // email addresses, webhook URLs, user IDs Format string `json:"format,omitempty"` // markdown | html | json | text Template string `json:"template,omitempty"` // template name or inline template @@ -1399,15 +1398,104 @@ type ValidationResult struct { ReplyContent string `json:"reply_content,omitempty"` // content for next turn (if NeedReply) } -// DeliveryResult - P4 delivery output +// DeliveryRequest - pushed to Delivery Center +// Agent only generates content, Delivery Center decides channels based on preferences +type DeliveryRequest struct { + Content *DeliveryContent `json:"content"` // Agent-generated content + Context *DeliveryContext `json:"context"` // Tracking info + // No Channels field - Delivery Center decides based on Robot/User preferences +} + +// DeliveryContent - content generated by Delivery Agent +type DeliveryContent struct { + Summary string `json:"summary"` // Brief summary (1-2 sentences) + Body string `json:"body"` // Full markdown report + Attachments []DeliveryAttachment `json:"attachments,omitempty"` // Output artifacts +} + +// DeliveryAttachment - task output attachment with metadata +// File uses wrapper format: __:// +// Example: __yao.attachment://ccd472d11feb96e03a3fc468f494045c +// Parse with attachment.Parse(value) → (uploader, fileID, isWrapper) +type DeliveryAttachment struct { + Title string `json:"title"` // Human-readable title, e.g., "Market Analysis Report" + Description string `json:"description,omitempty"` // Description of what this artifact is + TaskID string `json:"task_id,omitempty"` // Which task produced this artifact + File string `json:"file"` // Wrapper format: __:// +} + +// DeliveryContext - tracking and audit info +type DeliveryContext struct { + MemberID string `json:"member_id"` // Robot member ID (globally unique) + ExecutionID string `json:"execution_id"` + TriggerType TriggerType `json:"trigger_type"` + TeamID string `json:"team_id"` +} + +// DeliveryPreferences - Robot/User delivery preferences (read by Delivery Center) +// Each channel supports multiple targets +type DeliveryPreferences struct { + Email *EmailPreference `json:"email,omitempty"` + Webhook *WebhookPreference `json:"webhook,omitempty"` + Process *ProcessPreference `json:"process,omitempty"` + // notify is handled automatically based on user subscriptions +} + +// EmailPreference - multiple email targets +type EmailPreference struct { + Enabled bool `json:"enabled"` + Targets []EmailTarget `json:"targets"` +} + +type EmailTarget struct { + To []string `json:"to"` // Recipient addresses + Template string `json:"template,omitempty"` // Email template ID + Subject string `json:"subject,omitempty"` // Subject template (default: content.Summary) +} + +// WebhookPreference - multiple webhook targets +type WebhookPreference struct { + Enabled bool `json:"enabled"` + Targets []WebhookTarget `json:"targets"` +} + +type WebhookTarget struct { + URL string `json:"url"` // Webhook URL + Method string `json:"method,omitempty"` // HTTP method (default: POST) + Headers map[string]string `json:"headers,omitempty"` // Custom headers + Secret string `json:"secret,omitempty"` // Signing secret +} + +// ProcessPreference - multiple Yao Process targets +type ProcessPreference struct { + Enabled bool `json:"enabled"` + Targets []ProcessTarget `json:"targets"` +} + +type ProcessTarget struct { + Process string `json:"process"` // Yao Process name, e.g., "orders.UpdateStatus" + Args []any `json:"args,omitempty"` // Additional args (DeliveryContent passed as first arg) +} + +// DeliveryResult - P4 delivery output (returned by Delivery Center) type DeliveryResult struct { - Type DeliveryType `json:"type"` - Success bool `json:"success"` - Recipients []string `json:"recipients,omitempty"` // who received - Content string `json:"content,omitempty"` // formatted content delivered - Details interface{} `json:"details,omitempty"` // channel-specific response - Error string `json:"error,omitempty"` - SentAt *time.Time `json:"sent_at,omitempty"` + RequestID string `json:"request_id"` // Delivery request ID + Content *DeliveryContent `json:"content"` // Agent-generated content + Results []ChannelResult `json:"results,omitempty"` // Results per channel + Success bool `json:"success"` // Overall success + Error string `json:"error,omitempty"` // Error if failed + SentAt *time.Time `json:"sent_at,omitempty"` // When delivery completed +} + +// ChannelResult - result for a single delivery target +type ChannelResult struct { + Type DeliveryType `json:"type"` // email | webhook | process + Target string `json:"target"` // Target identifier (email, URL, process name) + Success bool `json:"success"` // Whether delivery succeeded + Recipients []string `json:"recipients,omitempty"` // Who received (for email) + Details interface{} `json:"details,omitempty"` // Channel-specific response + Error string `json:"error,omitempty"` // Error message if failed + SentAt *time.Time `json:"sent_at,omitempty"` // When this target was delivered } // LearningEntry - knowledge to save @@ -1893,3 +1981,493 @@ Supported assertion types: - `type` - type checking (with optional path) - `script` - custom script validation - `agent` - AI agent validation + +--- + +## 6. P4 Delivery Implementation + +### 6.1 Overview + +P4 Delivery summarizes P3 execution results and delivers to configured channels. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ delivery.go (P4 Entry) │ +│ - DeliveryExecution: main entry point │ +│ - Calls Delivery Agent with full execution context │ +│ - Routes DeliveryContent to configured channels │ +└─────────────────────┬───────────────────────────────────────┘ + │ + ┌────────────┴────────────┐ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Delivery Agent │ │ Delivery Center │ +│ - Summarize │ │ - sendEmail() │ +│ - Format body │ │ - postWebhook() │ +│ - List files │ │ - callProcess() │ +└─────────────────┘ └─────────────────┘ +``` + +### 6.2 Delivery Request Structure + +P4 generates a `DeliveryRequest` with **only content** and pushes to Delivery Center. +**Delivery Center decides channels** based on Robot/User preferences. + +```go +// DeliveryRequest - pushed to Delivery Center +// No Channels - Delivery Center decides based on preferences +type DeliveryRequest struct { + Content *DeliveryContent `json:"content"` // Agent-generated content + Context *DeliveryContext `json:"context"` // Tracking info +} + +// DeliveryContent - content generated by Delivery Agent +type DeliveryContent struct { + Summary string `json:"summary"` // Brief 1-2 sentence summary + Body string `json:"body"` // Full markdown report + Attachments []DeliveryAttachment `json:"attachments,omitempty"` // Output artifacts from P3 +} + +// DeliveryAttachment - file attachment with metadata +type DeliveryAttachment struct { + Title string `json:"title"` // Human-readable title + Description string `json:"description,omitempty"` // What this artifact is + TaskID string `json:"task_id,omitempty"` // Which task produced this + File string `json:"file"` // Wrapper: __:// +} + +// DeliveryContext - tracking and audit info +type DeliveryContext struct { + MemberID string `json:"member_id"` // Robot member ID (globally unique) + ExecutionID string `json:"execution_id"` + TriggerType TriggerType `json:"trigger_type"` + TeamID string `json:"team_id"` +} +``` + +**Example DeliveryRequest:** + +```json +{ + "content": { + "summary": "Sales report completed: 15 new leads", + "body": "## Weekly Sales Report\n...", + "attachments": [{"title": "Report.pdf", "file": "__yao.attachment://abc123"}] + }, + "context": { + "member_id": "mem_abc123", + "execution_id": "exec_xyz789", + "trigger_type": "clock", + "team_id": "team_123" + } +} +``` + +**Channel Decision by Delivery Center:** + +Delivery Center reads Robot/User preferences and executes delivery to all enabled targets: + +```go +// DeliveryPreferences - from Robot config (each channel supports multiple targets) +type DeliveryPreferences struct { + Email *EmailPreference `json:"email,omitempty"` + Webhook *WebhookPreference `json:"webhook,omitempty"` + Process *ProcessPreference `json:"process,omitempty"` +} + +type EmailPreference struct { + Enabled bool `json:"enabled"` + Targets []EmailTarget `json:"targets"` // Multiple email targets +} + +type WebhookPreference struct { + Enabled bool `json:"enabled"` + Targets []WebhookTarget `json:"targets"` // Multiple webhook URLs +} + +type ProcessPreference struct { + Enabled bool `json:"enabled"` + Targets []ProcessTarget `json:"targets"` // Multiple Yao Process calls +} +``` + +### 6.3 File Wrapper Format + +Attachments use the standard `yao/attachment` wrapper format: + +```go +// Format: __:// +// Example: __yao.attachment://ccd472d11feb96e03a3fc468f494045c + +import "github.com/yaoapp/yao/attachment" + +// Parse wrapper to get uploader and fileID +uploader, fileID, isWrapper := attachment.Parse(wrapper) +// uploader: "__yao.attachment" +// fileID: "ccd472d11feb96e03a3fc468f494045c" +// isWrapper: true + +// Get file info +manager := attachment.Managers[uploader] +fileInfo, err := manager.Info(ctx, fileID) + +// Read file content as base64 +base64Content := attachment.Base64(ctx, wrapper) + +// Read with data URI format +dataURI := attachment.Base64(ctx, wrapper, true) +// "data:image/png;base64,..." +``` + +### 6.4 Delivery Agent + +The Delivery Agent **only generates content**, does NOT decide channels. +Channel decisions are made by Delivery Center based on Robot/User preferences. + +**Input:** +```go +type DeliveryAgentInput struct { + Robot *Robot `json:"robot"` // Robot identity and config + TriggerType TriggerType `json:"trigger"` // clock | human | event + Inspiration *InspirationReport `json:"inspiration"` // P0 (clock only) + Goals *Goals `json:"goals"` // P1 + Tasks []Task `json:"tasks"` // P2 + Results []TaskResult `json:"results"` // P3 +} +``` + +**Output:** +```go +// DeliveryAgentOutput - only content, no channels +type DeliveryAgentOutput struct { + Content *DeliveryContent `json:"content"` // Generated content +} +``` + +**Agent Responsibilities:** + +The agent focuses on content generation: +- **Summary**: Brief 1-2 sentence summary of execution results +- **Body**: Full markdown report with details +- **Attachments**: Select which P3-generated files to include + +**Example Output:** + +```json +{ + "content": { + "summary": "Sales report completed: 15 new leads processed, 3 high-priority", + "body": "## Weekly Sales Report\n\n### Summary\n- Total leads: 15\n- High priority: 3\n...", + "attachments": [ + {"title": "Sales Report.pdf", "task_id": "task_1", "file": "__yao.attachment://abc123"}, + {"title": "Lead Analysis.xlsx", "task_id": "task_2", "file": "__yao.attachment://def456"} + ] + } +} +``` + +### 6.5 Global Email Configuration + +Email delivery uses global configuration for channel selection and Robot-specific sender identity: + +```go +// types/config_global.go + +// DefaultEmailChannel returns the default messenger channel name +// Default: "email" (maps to messengers/channels.yao) +func DefaultEmailChannel() string + +// SetDefaultEmailChannel sets the default channel (call during agent init) +func SetDefaultEmailChannel(channel string) +``` + +**Usage:** +- `DefaultEmailChannel()` - returns the messenger channel name for email delivery +- `Robot.RobotEmail` - used as the `From` address when sending emails +- If `RobotEmail` is empty, falls back to provider's default `from` address + +### 6.6 Delivery Center + +The Delivery Center receives `DeliveryRequest`, reads preferences, and executes delivery to **all enabled targets**. + +**Current implementation:** Internal to P4 (in `executor/delivery.go`) +**Future:** Can be extracted to standalone `yao/delivery` package + +```go +// DeliveryCenter - handles delivery execution to multiple targets +type DeliveryCenter struct { + messenger *messenger.Manager +} + +// Deliver - main entry point +func (dc *DeliveryCenter) Deliver(ctx context.Context, req *DeliveryRequest) *DeliveryResult { + requestID := generateID() + prefs := dc.getDeliveryPreferences(ctx, req.Context.MemberID) + + var results []ChannelResult + allSuccess := true + + // Email - send to all targets (robot passed for From address) + if prefs.Email != nil && prefs.Email.Enabled { + for _, target := range prefs.Email.Targets { + result := dc.sendEmail(ctx, req.Content, target, req.Context, robot) + results = append(results, result) + if !result.Success { + allSuccess = false + } + } + } + + // Webhook - POST to all targets + if prefs.Webhook != nil && prefs.Webhook.Enabled { + for _, target := range prefs.Webhook.Targets { + result := dc.postWebhook(ctx, req.Content, target) + results = append(results, result) + if !result.Success { + allSuccess = false + } + } + } + + // Process - call all targets + if prefs.Process != nil && prefs.Process.Enabled { + for _, target := range prefs.Process.Targets { + result := dc.callProcess(ctx, req.Content, target) + results = append(results, result) + if !result.Success { + allSuccess = false + } + } + } + + // Future: auto-notify based on user subscriptions + // dc.sendNotifications(ctx, req) + + return &DeliveryResult{ + RequestID: requestID, + Content: req.Content, + Success: allSuccess, + Results: results, + } +} +``` + +### 6.7 Channel Handlers + +Each delivery channel is handled by dedicated methods in DeliveryCenter: + +```go +// sendEmail - send to a single email target +// Uses Robot.RobotEmail as From address and global DefaultEmailChannel() +func (dc *DeliveryCenter) sendEmail( + ctx context.Context, + content *DeliveryContent, + target EmailTarget, + deliveryCtx *DeliveryContext, + robot *Robot, +) ChannelResult { + // Convert attachments to messenger format + var attachments []messenger.Attachment + for _, att := range content.Attachments { + uploader, fileID, _ := attachment.Parse(att.File) + manager := attachment.Managers[uploader] + data, _ := manager.Read(ctx, fileID) + info, _ := manager.Info(ctx, fileID) + + attachments = append(attachments, messenger.Attachment{ + Filename: att.Title, + ContentType: info.ContentType, + Content: data, + }) + } + + subject := content.Summary + if target.Subject != "" { + subject = target.Subject + } + + msg := &messenger.Message{ + To: target.To, + Subject: subject, + Body: content.Body, + Attachments: attachments, + } + + // Set From address from Robot's email (if configured) + if robot != nil && robot.RobotEmail != "" { + msg.From = robot.RobotEmail + } + + // Use global default email channel + channel := DefaultEmailChannel() // from types/config_global.go + err := dc.messenger.Send(ctx, channel, msg) + + now := time.Now() + return ChannelResult{ + Type: DeliveryEmail, + Target: strings.Join(target.To, ","), + Success: err == nil, + Recipients: target.To, + SentAt: &now, + Error: errStr(err), + } +} + +// postWebhook - POST to a single webhook target +func (dc *DeliveryCenter) postWebhook(ctx context.Context, content *DeliveryContent, target WebhookTarget) ChannelResult { + payload, _ := json.Marshal(content) + req, _ := http.NewRequestWithContext(ctx, "POST", target.URL, bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + + // Add custom headers + for k, v := range target.Headers { + req.Header.Set(k, v) + } + + resp, err := http.DefaultClient.Do(req) + now := time.Now() + + if err != nil { + return ChannelResult{ + Type: DeliveryWebhook, + Target: target.URL, + Success: false, + Error: err.Error(), + SentAt: &now, + } + } + defer resp.Body.Close() + + success := resp.StatusCode < 400 + return ChannelResult{ + Type: DeliveryWebhook, + Target: target.URL, + Success: success, + Details: map[string]interface{}{"status_code": resp.StatusCode}, + Error: ternary(!success, fmt.Sprintf("HTTP %d", resp.StatusCode), ""), + SentAt: &now, + } +} + +// callProcess - call a single Yao Process target +func (dc *DeliveryCenter) callProcess(ctx context.Context, content *DeliveryContent, target ProcessTarget) ChannelResult { + // DeliveryContent as first arg, then additional args + args := append([]interface{}{content}, target.Args...) + + proc := process.Of(target.Process, args...) + result, err := proc.Execute() + + now := time.Now() + return ChannelResult{ + Type: DeliveryProcess, + Target: target.Process, + Success: err == nil, + Details: map[string]interface{}{ + "process": target.Process, + "result": result, + }, + Error: errStr(err), + SentAt: &now, + } +} +``` + +**Note on Notifications:** + +`notify` is NOT configured per-Robot. Future Delivery Center will: +1. Check user subscription preferences after receiving DeliveryRequest +2. Automatically send in-app notifications to subscribed users +3. This is transparent to P4 and Delivery Agent + +### 6.8 Execution Persistence + +Robot execution history is stored in `__yao.agent_execution` table for UI display: + +```go +// Model: yao/models/agent/execution.mod.yao +// Table: __yao.agent_execution + +type ExecutionRecord struct { + ID int64 `json:"id,omitempty"` // Auto-increment primary key + ExecutionID string `json:"execution_id"` // Unique execution identifier + MemberID string `json:"member_id"` // Robot member ID (globally unique) + TeamID string `json:"team_id"` // Team ID + JobID string `json:"job_id,omitempty"` // Linked job.Job ID + TriggerType TriggerType `json:"trigger_type"` // clock | human | event + + // Status tracking (synced with runtime Execution) + Status ExecStatus `json:"status"` // pending | running | completed | failed | cancelled + Phase Phase `json:"phase"` // Current phase + Current *CurrentState `json:"current,omitempty"`// Current executing state (task index, progress) + Error string `json:"error,omitempty"` // Error message if failed + + // Trigger input + Input *TriggerInput `json:"input,omitempty"` // Original trigger input + + // Phase outputs (P0-P5) + Inspiration *InspirationReport `json:"inspiration,omitempty"` // P0 result + Goals *Goals `json:"goals,omitempty"` // P1 result + Tasks []Task `json:"tasks,omitempty"` // P2 result + Results []TaskResult `json:"results,omitempty"` // P3 results + Delivery *DeliveryResult `json:"delivery,omitempty"` // P4 result + Learning []LearningEntry `json:"learning,omitempty"` // P5 entries + + // Timestamps + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// CurrentState - current executing state (for JSON storage) +type CurrentState struct { + TaskIndex int `json:"task_index"` // index in Tasks slice + Progress string `json:"progress,omitempty"` // human-readable progress (e.g., "2/5 tasks") +} +``` + +**Store Implementation:** + +```go +// store/execution.go +type ExecutionStore struct { + modelID string // "__yao.agent.execution" +} + +func NewExecutionStore() *ExecutionStore + +// Save creates or updates an execution record +func (s *ExecutionStore) Save(ctx context.Context, record *ExecutionRecord) error + +// Get retrieves an execution by execution_id +func (s *ExecutionStore) Get(ctx context.Context, executionID string) (*ExecutionRecord, error) + +// List retrieves executions with filters +func (s *ExecutionStore) List(ctx context.Context, opts *ListOptions) ([]*ExecutionRecord, error) + +// UpdatePhase updates the current phase and its data +func (s *ExecutionStore) UpdatePhase(ctx context.Context, executionID string, phase Phase, data interface{}) error + +// UpdateStatus updates the execution status +func (s *ExecutionStore) UpdateStatus(ctx context.Context, executionID string, status ExecStatus, errorMsg string) error + +// UpdateCurrent updates the current executing state +func (s *ExecutionStore) UpdateCurrent(ctx context.Context, executionID string, current *CurrentState) error + +// Delete removes an execution record +func (s *ExecutionStore) Delete(ctx context.Context, executionID string) error + +// Conversion helpers +func FromExecution(exec *Execution) *ExecutionRecord +func (r *ExecutionRecord) ToExecution() *Execution + +type ListOptions struct { + MemberID string // Filter by robot member ID (globally unique) + TeamID string // Filter by team + Status ExecStatus // Filter by status + TriggerType TriggerType // Filter by trigger + Limit int // Max records to return (default: 100) + Offset int // Skip records for pagination + OrderBy string // e.g., "start_time desc" +} +``` diff --git a/agent/robot/TODO.md b/agent/robot/TODO.md index d6ec3ca1..424cb65f 100644 --- a/agent/robot/TODO.md +++ b/agent/robot/TODO.md @@ -62,7 +62,7 @@ - [x] `RobotStatus` - robot status (idle, working, paused, error, maintenance) - [x] `InterventionAction` - human actions (task.add, goal.adjust, etc.) - [x] `Priority` - priority levels (high, normal, low) -- [x] `DeliveryType` - delivery types (email, file, webhook, notify) +- [x] `DeliveryType` - delivery types (email, webhook, process, notify) - [x] `DedupResult` - dedup results (skip, merge, proceed) - [x] `EventSource` - event sources (webhook, database) - [x] `LearningType` - learning types (execution, feedback, insight) @@ -555,7 +555,7 @@ yao-dev-app/assistants/ - [x] `robot/delivery/package.yao` - config - [x] `robot/delivery/prompts.yml` - system prompt: - - Input: Task results, delivery target (email, report, notification) + - Input: Full execution context (P0-P3 results) - Output: Formatted delivery content - Style: Clear, professional @@ -869,92 +869,285 @@ Created new `yao/assert` package for universal assertion/validation: **Depends on:** Phase 9 (P3 Run) -### 10.1 Delivery Agent Setup +### 10.1 Execution Persistence (Prerequisite) ✅ -- [ ] `robot/delivery/package.yao` - Delivery Agent config -- [ ] `robot/delivery/prompts.yml` - delivery prompts +> **Background:** Each Robot execution (P0-P5) needs persistent storage for UI history queries. -### 10.2 Implementation +- [x] `yao/models/agent/execution.mod.yao` - Execution record model (`agent_execution` table) + - [x] id, execution_id (unique) + - [x] member_id (globally unique), team_id, job_id + - [x] trigger_type (enum: clock, human, event) + - [x] **Status tracking** (synced with runtime Execution): + - [x] status (enum: pending, running, completed, failed, cancelled) + - [x] phase (enum: inspiration, goals, tasks, run, delivery, learning) + - [x] current (JSON) - current executing state (task_index, progress) + - [x] error - error message if failed + - [x] input (JSON) - trigger input + - [x] **Phase outputs** (P0-P5): + - [x] inspiration (JSON) - P0 output + - [x] goals (JSON) - P1 output + - [x] tasks (JSON) - P2 output + - [x] results (JSON) - P3 output + - [x] delivery (JSON) - P4 output + - [x] learning (JSON) - P5 output + - [x] **Timestamps**: start_time, end_time, created_at, updated_at + - [x] Relations: member (hasOne __yao.member) +- [x] `agent/robot/store/execution.go` - Execution record storage + - [x] `Save(ctx, record)` - create or update execution record + - [x] `Get(ctx, execID)` - get execution by ID + - [x] `List(ctx, opts)` - query execution history with filters + - [x] `UpdatePhase(ctx, execID, phase, data)` - update current phase and data + - [x] `UpdateStatus(ctx, execID, status, error)` - update execution status + - [x] `UpdateCurrent(ctx, execID, current)` - update current executing state + - [x] `Delete(ctx, execID)` - delete execution record + - [x] `FromExecution(exec, robotID)` - convert runtime Execution to record + - [x] `ToExecution()` - convert record to runtime Execution +- [x] Tests: `agent/robot/store/execution_test.go` (9 test groups, all passing) +- [x] Integrate into Executor - call `UpdatePhase()` after each phase completes + - [x] Added `SkipPersistence` config option to `executor/types/Config` + - [x] Added `ExecutionStore` to `executor/standard/Executor` + - [x] Save execution record at start of `Execute()` + - [x] Call `UpdatePhase()` after each phase completes in `runPhase()` + - [x] Call `UpdateStatus()` on status changes (running, completed, failed) -- [ ] `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.2 Messenger Attachment Support ✅ -### 10.3 Tests +> **Conclusion:** All email providers now support attachments. -- [ ] `executor/delivery_test.go` - P4 delivery -- [ ] Test: delivery content generated correctly -- [ ] Test: email delivery (mock or real) -- [ ] Test: file delivery to configured path +**Implementation Status:** + +| Provider | Attachment Support | Implementation | +|----------|-------------------|----------------| +| Twilio/SendGrid | ✅ Supported | `buildAttachments()` - base64 encoded | +| Mailgun | ✅ Supported | `sendEmailWithAttachments()` - multipart/form-data | +| SMTP (mailer) | ✅ Supported | `buildMessageWithAttachments()` - MIME multipart/mixed | + +**Features Supported:** +- Regular attachments (Content-Disposition: attachment) +- Inline attachments (Content-Disposition: inline) with Content-ID for HTML embedding +- Multiple attachments per email +- Automatic content type detection +- Base64 encoding for SMTP (RFC 2045 compliant, 76-char line wrapping) + +**Tests Added:** +- `messenger/providers/mailgun/mailgun_test.go`: + - `TestSend_EmailWithAttachments_MockServer` + - `TestSend_EmailWithInlineAttachment_MockServer` + - `TestSend_EmailWithAttachments_RealAPI` +- `messenger/providers/mailer/mailer_test.go`: + - `TestBuildMessage_WithAttachments` (single, multiple, inline, no attachments) + - `TestSend_EmailWithAttachments_RealAPI` + +```go +// messenger/types/types.go +type Attachment struct { + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Content []byte `json:"content"` + Inline bool `json:"inline,omitempty"` + CID string `json:"cid,omitempty"` +} +``` + +Supported channels: +- [x] Email - Full attachment support +- [x] SMS - No attachment (text only) +- [x] WhatsApp - TBD + +### 10.3 Type Updates (Prerequisite) ✅ + +- [x] Update `types/enums.go` - Update `DeliveryType` enum + - [x] Remove `DeliveryFile` + - [x] Add `DeliveryProcess` +- [x] Update `types/robot.go` - Delivery types for new architecture + - [x] `DeliveryResult` - update to new structure (RequestID, Content, Results[]) + - [x] Add `DeliveryContent` struct + - [x] Add `DeliveryAttachment` struct + - [x] Add `DeliveryRequest` struct + - [x] Add `DeliveryContext` struct + - [x] Add `DeliveryPreferences` struct (with Email, Webhook, Process) + - [x] Add `EmailPreference`, `EmailTarget` structs + - [x] Add `WebhookPreference`, `WebhookTarget` structs + - [x] Add `ProcessPreference`, `ProcessTarget` structs + - [x] Add `ChannelResult` struct (with Target field) +- [x] Update `types/enums_test.go` - Update DeliveryType tests +- [x] Update `types/robot_test.go` - Update delivery result tests + +### 10.4 Delivery Agent Setup + +- [x] `robot/delivery/package.yao` - Delivery Agent config +- [x] `robot/delivery/prompts.yml` - delivery prompts + - [x] Input: Full execution context (P0-P3 results) + - [x] Output: DeliveryContent (Summary, Body, Attachments) - **only content, no channels** + - [x] Agent focuses on content generation, NOT channel selection + +### 10.5 Delivery Content Structure + +```go +// DeliveryRequest - pushed to Delivery Center +// No Channels - Delivery Center decides based on preferences +type DeliveryRequest struct { + Content *DeliveryContent `json:"content"` // Agent-generated content + Context *DeliveryContext `json:"context"` // Tracking info +} + +// DeliveryContent - Content generated by Delivery Agent (only content) +type DeliveryContent struct { + Summary string `json:"summary"` // Brief 1-2 sentence summary + Body string `json:"body"` // Full markdown report + Attachments []DeliveryAttachment `json:"attachments,omitempty"` // Output artifacts from P3 +} + +// DeliveryAttachment - Task output attachment with metadata +type DeliveryAttachment struct { + Title string `json:"title"` // Human-readable title + Description string `json:"description,omitempty"` // What this artifact is + TaskID string `json:"task_id,omitempty"` // Which task produced this + File string `json:"file"` // Wrapper: __:// +} + +// DeliveryContext - tracking info +type DeliveryContext struct { + MemberID string `json:"member_id"` // Robot member ID (globally unique) + ExecutionID string `json:"execution_id"` + TriggerType TriggerType `json:"trigger_type"` // clock | human | event + TeamID string `json:"team_id"` +} +``` + +**Key Design:** +- **Agent only generates content** (Summary, Body, Attachments) +- **Delivery Center decides channels** based on Robot/User preferences +- If webhook configured, every execution pushes automatically + +**File Wrapper:** +- Format: `__://` +- Parse: `attachment.Parse(value)` → `(uploader, fileID, isWrapper)` +- Read: `attachment.Base64(ctx, value)` → base64 content + +**Delivery Channels (each supports multiple targets):** +| Channel | Description | Multiple Targets | +|---------|-------------|------------------| +| `email` | Send via yao/messenger | ✅ Multiple recipients | +| `webhook` | POST to external URL | ✅ Multiple URLs | +| `process` | Yao Process call | ✅ Multiple processes | +| `notify` | In-app notification | Future (auto by subscriptions) | + +### 10.6 Implementation + +**P4 Entry (executor/delivery.go):** +- [x] `RunDelivery(ctx, exec, data)` - P4 entry point + - [x] Call Delivery Agent to generate content (only content, no channels) + - [x] Build DeliveryRequest (Content + Context) + - [x] Push to Delivery Center + - [x] Store DeliveryResult in exec.Delivery + +**Delivery Center (executor/delivery_center.go):** +- [x] `DeliveryCenter.Deliver(ctx, request)` - main entry + - [x] Read Robot/User delivery preferences + - [x] Iterate through all enabled targets for each channel + - [x] Aggregate ChannelResults into DeliveryResult + +**Channel Handlers (each supports multiple targets):** +- [x] `sendEmail()` - uses yao/messenger + - [x] Convert DeliveryAttachment to messenger.Attachment + - [x] Support multiple EmailTarget + - [x] Support custom subject_template per target + - [x] Use `Robot.RobotEmail` as From address (if configured) + - [x] Use global `DefaultEmailChannel()` for messenger channel selection +- [x] `postWebhook()` - POST JSON + - [x] POST DeliveryContent as JSON payload + - [x] Support multiple WebhookTarget + - [x] Support custom headers per target +- [x] `callProcess()` - Yao Process call + - [x] DeliveryContent as first arg + - [x] Support multiple ProcessTarget + - [x] Support additional args per target + +### 10.7 Tests + +- [x] `executor/delivery_test.go` - P4 delivery +- [x] Test: Delivery Agent generates content (only content) +- [x] Test: DeliveryCenter reads preferences +- [x] Test: Multiple email targets (TestDeliveryCenterEmail) +- [x] Test: Multiple webhook targets +- [x] Test: Multiple process targets (TestDeliveryCenterProcess) +- [x] Test: Mixed channels (email + webhook + process) (TestDeliveryCenterAllChannels) +- [x] Test: sendEmail with attachments (TestDeliveryCenterEmail) +- [x] Test: postWebhook with custom headers +- [x] Test: callProcess with args (TestDeliveryCenterProcess) +- [x] Test: Partial success (some targets fail) +- [x] Test: DeliveryResult aggregation --- -## Phase 11: P5 Learning Implementation +## Phase 11: API & Integration -**Goal:** Implement P5 (Learning). Full execution flow complete. +**Goal:** Complete API implementation, end-to-end tests. Main flow: P0 → P1 → P2 → P3 → P4. **Depends on:** Phase 10 (P4 Delivery) -### 11.1 Learning Agent Setup +> **Note:** P5 Learning is an advanced feature (async, background, user-invisible). +> Main flow works without it. Moved to Phase 12 (Advanced Features). -- [ ] `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 - -### 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 - -### 11.4 Tests - -- [ ] `executor/learning_test.go` - P5 learning -- [ ] Test: learnings extracted from execution -- [ ] Test: learnings saved to KB -- [ ] Test: KB can be queried for past learnings - ---- - -## Phase 12: API & Integration - -**Goal:** Complete API implementation, end-to-end tests. - -### 12.1 API Implementation +### 11.1 API Implementation - [ ] `api/api.go` - implement all Go API functions - [ ] `api/process.go` - implement all Process handlers - [ ] `api/jsapi.go` - implement JSAPI -### 12.2 End-to-End Tests +### 11.2 End-to-End Tests -- [ ] Full clock trigger flow (P0 → P5) -- [ ] Human intervention flow (P1 → P5) -- [ ] Event trigger flow (P1 → P5) +- [ ] Full clock trigger flow (P0 → P1 → P2 → P3 → P4) +- [ ] Human intervention flow (P1 → P2 → P3 → P4) +- [ ] Event trigger flow (P1 → P2 → P3 → P4) - [ ] Concurrent execution test - [ ] Pause/Resume/Stop test -### 12.3 Integration with OpenAPI +### 11.3 Integration with OpenAPI - [ ] HTTP endpoints for human intervention - [ ] Webhook endpoints for events --- -## Phase 13: Advanced Features +## Phase 12: Advanced Features -**Goal:** Implement dedup, semantic dedup, plan queue. +**Goal:** Implement P5 Learning, dedup, semantic dedup, plan queue. -### 13.1 Fast Dedup (Time-Window) +> **Note:** These are optional advanced features. Main flow works without them. + +### 12.1 P5 Learning Implementation + +> **Background:** P5 Learning is async, runs after P4 Delivery completes. +> User doesn't wait for it. Results stored in private KB for future reference. + +#### 12.1.1 Learning Agent Setup + +- [ ] `robot/learning/package.yao` - Learning Agent config +- [ ] `robot/learning/prompts.yml` - learning prompts + +#### 12.1.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 + +#### 12.1.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 + +#### 12.1.4 Tests + +- [ ] `executor/learning_test.go` - P5 learning +- [ ] Test: learnings extracted from execution +- [ ] Test: learnings saved to KB +- [ ] Test: KB can be queried for past learnings + +### 12.2 Fast Dedup (Time-Window) > **Note:** Manager has `// TODO: dedup check` comment placeholder. Integrate after implementation. @@ -966,13 +1159,13 @@ Created new `yao/assert` package for universal assertion/validation: - [ ] Integrate into Manager.Tick() - [ ] Test: dedup check/mark, window expiry -### 13.2 Semantic Dedup +### 12.3 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 -### 13.3 Plan Queue +### 12.4 Plan Queue - [ ] `plan/plan.go` - plan queue implementation - [ ] Store planned tasks/goals @@ -1100,13 +1293,15 @@ func TestWithLLM(t *testing.T) { | 7. P1 Goals | ✅ | Goal Generation Agent integration | | 8. P2 Tasks | ✅ | Task Planning Agent integration | | 9. P3 Run | ✅ | Task execution + validation + yao/assert + multi-turn conversation | -| 10. P4 Delivery | ⬜ | Output delivery (email/file/webhook/notify) | -| 11. P5 Learning | ⬜ | Learning Agent + KB save | -| 12. API & Integration | ⬜ | Complete API, end-to-end tests | -| 13. Advanced | ⬜ | Semantic dedup, plan queue, Sandbox mode (requires container infrastructure) | +| 10. P4 Delivery | ✅ | Output delivery (email/webhook/process, notify future) | +| 11. API & Integration | ⬜ | Complete API, end-to-end tests (main flow: P0→P1→P2→P3→P4) | +| 12. Advanced | ⬜ | P5 Learning, dedup, plan queue, Sandbox mode | Legend: ⬜ Not started | 🟡 In progress | ✅ Complete +**Main Flow (MVP):** P0 Inspiration → P1 Goals → P2 Tasks → P3 Run → P4 Delivery +**Advanced (Optional):** P5 Learning (async), Dedup, Plan Queue, Sandbox + --- ## Quick Commands diff --git a/agent/robot/executor/dryrun/executor.go b/agent/robot/executor/dryrun/executor.go index 0aecf4d9..5aff1390 100644 --- a/agent/robot/executor/dryrun/executor.go +++ b/agent/robot/executor/dryrun/executor.go @@ -168,7 +168,11 @@ func (e *Executor) mockPhaseOutput(exec *robottypes.Execution, phase robottypes. } case robottypes.PhaseDelivery: exec.Delivery = &robottypes.DeliveryResult{ - Type: robottypes.DeliveryNotify, + RequestID: "dryrun-" + exec.ID, + Content: &robottypes.DeliveryContent{ + Summary: "Dry-run delivery completed", + Body: "# Dry-run Delivery\n\nThis is a simulated delivery result.", + }, Success: true, } case robottypes.PhaseLearning: diff --git a/agent/robot/executor/sandbox/executor.go b/agent/robot/executor/sandbox/executor.go index 3a066091..7a7d3b6f 100644 --- a/agent/robot/executor/sandbox/executor.go +++ b/agent/robot/executor/sandbox/executor.go @@ -201,7 +201,11 @@ func (e *Executor) mockPhaseOutput(exec *robottypes.Execution, phase robottypes. } case robottypes.PhaseDelivery: exec.Delivery = &robottypes.DeliveryResult{ - Type: robottypes.DeliveryNotify, + RequestID: "sandbox-" + exec.ID, + Content: &robottypes.DeliveryContent{ + Summary: "Sandbox delivery completed", + Body: "# Sandbox Delivery\n\nThis is a simulated sandbox delivery result.", + }, Success: true, } case robottypes.PhaseLearning: diff --git a/agent/robot/executor/standard/delivery.go b/agent/robot/executor/standard/delivery.go index 82c040df..0d050e1d 100644 --- a/agent/robot/executor/standard/delivery.go +++ b/agent/robot/executor/standard/delivery.go @@ -1,32 +1,397 @@ package standard import ( + "encoding/json" + "fmt" + "strings" + "time" + robottypes "github.com/yaoapp/yao/agent/robot/types" ) // RunDelivery executes P4: Delivery phase -// Delivers results to configured targets +// Calls the Delivery Agent to generate content, then routes to Delivery Center // // Input: -// - TaskResults (from P3) -// - Delivery config from robot +// - Full execution context (P0-P3) +// - Robot config // // Output: -// - DeliveryResult with success status +// - DeliveryResult with content and channel results // -// Delivery Types: -// - DeliveryEmail: Send email -// - DeliveryNotify: Send notification -// - DeliveryWebhook: Call webhook -// - DeliveryStore: Store to database -// -// TODO: Implement real delivery +// Process: +// 1. Call Delivery Agent with full execution context +// 2. Agent generates DeliveryContent (summary, body, attachments) +// 3. Route content to Delivery Center for actual delivery func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error { - e.simulateStreamDelay() - - exec.Delivery = &robottypes.DeliveryResult{ - Type: robottypes.DeliveryNotify, - Success: true, + // Get robot for identity and resources + robot := exec.GetRobot() + if robot == nil { + return fmt.Errorf("robot not found in execution") } + + // Get agent ID for delivery phase + agentID := "__yao.delivery" // default + if robot.Config != nil && robot.Config.Resources != nil { + agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseDelivery) + } + + // Build input for Delivery Agent + formatter := NewInputFormatter() + userContent := formatter.FormatDeliveryInput(exec, robot) + + if userContent == "" { + return fmt.Errorf("no content available for delivery generation") + } + + // Call Delivery Agent + caller := NewAgentCaller() + result, err := caller.CallWithMessages(ctx, agentID, userContent) + if err != nil { + return fmt.Errorf("delivery agent (%s) call failed: %w", agentID, err) + } + + // Parse response as JSON + // Delivery Agent returns: { "content": { "summary": "...", "body": "...", "attachments": [...] } } + data, err := result.GetJSON() + if err != nil { + // Fallback: if not JSON, create minimal content from raw text + content := result.GetText() + if content == "" { + return fmt.Errorf("delivery agent returned empty response") + } + exec.Delivery = &robottypes.DeliveryResult{ + RequestID: generateRequestID(exec.ID), + Content: &robottypes.DeliveryContent{ + Summary: truncateSummary(content, 200), + Body: content, + }, + Success: true, + } + return e.routeToDeliveryCenter(ctx, exec, robot) + } + + // Build DeliveryContent from JSON + content := parseDeliveryContent(data) + if content == nil { + return fmt.Errorf("delivery agent (%s) returned invalid content", agentID) + } + + // Build DeliveryResult + exec.Delivery = &robottypes.DeliveryResult{ + RequestID: generateRequestID(exec.ID), + Content: content, + Success: true, + } + + // Route to Delivery Center for actual delivery + return e.routeToDeliveryCenter(ctx, exec, robot) +} + +// routeToDeliveryCenter sends content to the Delivery Center for actual delivery +// The Delivery Center decides which channels to use based on robot/user preferences +func (e *Executor) routeToDeliveryCenter(ctx *robottypes.Context, exec *robottypes.Execution, robot *robottypes.Robot) error { + if exec.Delivery == nil || exec.Delivery.Content == nil { + return fmt.Errorf("no delivery content to route") + } + + // Get delivery preferences from robot config + var prefs *robottypes.DeliveryPreferences + if robot.Config != nil { + prefs = robot.Config.Delivery + } + + // If no preferences configured, skip delivery (content is still saved in exec.Delivery) + if prefs == nil || !hasActiveChannels(prefs) { + // No channels configured - mark as success but with no results + exec.Delivery.Success = true + return nil + } + + // Create Delivery Center and execute + center := NewDeliveryCenter() + results, err := center.Deliver(ctx, exec.Delivery.Content, &robottypes.DeliveryContext{ + MemberID: exec.MemberID, + ExecutionID: exec.ID, + TriggerType: exec.TriggerType, + TeamID: exec.TeamID, + }, prefs, robot) + + // Update delivery result + exec.Delivery.Results = results + now := time.Now() + exec.Delivery.SentAt = &now + + if err != nil { + exec.Delivery.Success = false + exec.Delivery.Error = err.Error() + return err + } + + // Check if all channels succeeded + allSuccess := true + for _, r := range results { + if !r.Success { + allSuccess = false + break + } + } + exec.Delivery.Success = allSuccess + return nil } + +// parseDeliveryContent parses the Delivery Agent response into DeliveryContent +func parseDeliveryContent(data map[string]interface{}) *robottypes.DeliveryContent { + if data == nil { + return nil + } + + // Try to get content object + contentData, ok := data["content"].(map[string]interface{}) + if !ok { + // Fallback: maybe the data itself is the content + contentData = data + } + + content := &robottypes.DeliveryContent{} + + // Parse summary + if summary, ok := contentData["summary"].(string); ok { + content.Summary = summary + } + + // Parse body + if body, ok := contentData["body"].(string); ok { + content.Body = body + } + + // Parse attachments + if attachments, ok := contentData["attachments"].([]interface{}); ok { + for _, att := range attachments { + if attMap, ok := att.(map[string]interface{}); ok { + attachment := parseDeliveryAttachment(attMap) + if attachment != nil { + content.Attachments = append(content.Attachments, *attachment) + } + } + } + } + + // Validate: at least summary or body should be present + if content.Summary == "" && content.Body == "" { + return nil + } + + return content +} + +// parseDeliveryAttachment parses a single attachment from the agent response +func parseDeliveryAttachment(data map[string]interface{}) *robottypes.DeliveryAttachment { + if data == nil { + return nil + } + + att := &robottypes.DeliveryAttachment{} + + if title, ok := data["title"].(string); ok { + att.Title = title + } + if desc, ok := data["description"].(string); ok { + att.Description = desc + } + if taskID, ok := data["task_id"].(string); ok { + att.TaskID = taskID + } + if file, ok := data["file"].(string); ok { + att.File = file + } + + // At minimum, need title and file + if att.Title == "" || att.File == "" { + return nil + } + + return att +} + +// generateRequestID generates a unique request ID for delivery tracking +func generateRequestID(execID string) string { + return fmt.Sprintf("dlv-%s-%d", execID, time.Now().UnixNano()%1000000) +} + +// getTaskDescription extracts a description from task messages +func getTaskDescription(task robottypes.Task) string { + if len(task.Messages) == 0 { + return task.GoalRef + } + + // Try to get text from first message + for _, msg := range task.Messages { + if content, ok := msg.Content.(string); ok && content != "" { + // Truncate if too long + if len(content) > 100 { + return content[:97] + "..." + } + return content + } + } + + // Fallback to goal reference + if task.GoalRef != "" { + return task.GoalRef + } + + return "Task " + task.ID +} + +// truncateSummary truncates text to maxLen characters +func truncateSummary(text string, maxLen int) string { + if len(text) <= maxLen { + return text + } + // Find last space before maxLen to avoid cutting words + truncated := text[:maxLen] + if idx := strings.LastIndex(truncated, " "); idx > maxLen/2 { + return truncated[:idx] + "..." + } + return truncated + "..." +} + +// hasActiveChannels checks if any delivery channel is configured +func hasActiveChannels(prefs *robottypes.DeliveryPreferences) bool { + if prefs == nil { + return false + } + if prefs.Email != nil && prefs.Email.Enabled && len(prefs.Email.Targets) > 0 { + return true + } + if prefs.Webhook != nil && prefs.Webhook.Enabled && len(prefs.Webhook.Targets) > 0 { + return true + } + if prefs.Process != nil && prefs.Process.Enabled && len(prefs.Process.Targets) > 0 { + return true + } + return false +} + +// FormatDeliveryInput formats the full execution context for the Delivery Agent +func (f *InputFormatter) FormatDeliveryInput(exec *robottypes.Execution, robot *robottypes.Robot) string { + if exec == nil { + return "" + } + + var sb strings.Builder + + // Robot identity + if robot != nil && robot.Config != nil && robot.Config.Identity != nil { + sb.WriteString("## 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**: ") + sb.WriteString(strings.Join(robot.Config.Identity.Duties, ", ")) + sb.WriteString("\n") + } + sb.WriteString("\n") + } + + // Trigger type + sb.WriteString("## Execution Context\n\n") + 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 { + duration := exec.EndTime.Sub(exec.StartTime) + sb.WriteString(fmt.Sprintf("- **Duration**: %s\n", duration.String())) + } + sb.WriteString("\n") + + // Inspiration (P0) - for clock trigger + 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 { + // Extract task description from messages if available + taskDesc := getTaskDescription(task) + sb.WriteString(fmt.Sprintf("%d. **%s** - %s\n", i+1, task.ID, taskDesc)) + sb.WriteString(fmt.Sprintf(" - Executor: %s (%s)\n", task.ExecutorID, task.ExecutorType)) + sb.WriteString(fmt.Sprintf(" - Status: %s\n", task.Status)) + if task.ExpectedOutput != "" { + sb.WriteString(fmt.Sprintf(" - Expected: %s\n", task.ExpectedOutput)) + } + } + sb.WriteString("\n") + } + + // Results (P3) - detailed + if len(exec.Results) > 0 { + sb.WriteString("## Results (P3)\n\n") + + successCount := 0 + failCount := 0 + + for _, result := range exec.Results { + if result.Success { + successCount++ + sb.WriteString(fmt.Sprintf("### ✓ Task: %s\n\n", result.TaskID)) + } else { + failCount++ + sb.WriteString(fmt.Sprintf("### ✗ Task: %s\n\n", result.TaskID)) + } + + sb.WriteString(fmt.Sprintf("- **Duration**: %dms\n", result.Duration)) + + // Validation + if result.Validation != nil { + if result.Validation.Passed { + sb.WriteString(fmt.Sprintf("- **Validation**: ✓ Passed (score: %.2f)\n", result.Validation.Score)) + } else { + sb.WriteString("- **Validation**: ✗ Failed\n") + if len(result.Validation.Issues) > 0 { + for _, issue := range result.Validation.Issues { + sb.WriteString(fmt.Sprintf(" - %s\n", issue)) + } + } + } + } + + // Output + if result.Output != nil { + sb.WriteString("\n**Output**:\n") + if output, err := json.MarshalIndent(result.Output, "", " "); err == nil { + sb.WriteString("```json\n") + sb.WriteString(string(output)) + sb.WriteString("\n```\n") + } else { + sb.WriteString(fmt.Sprintf("%v\n", result.Output)) + } + } + + // Error + if result.Error != "" { + sb.WriteString(fmt.Sprintf("\n**Error**: %s\n", result.Error)) + } + + sb.WriteString("\n") + } + + // Summary + sb.WriteString(fmt.Sprintf("### Summary\n\n- **Total Tasks**: %d\n- **Succeeded**: %d\n- **Failed**: %d\n\n", + len(exec.Results), successCount, failCount)) + } + + return sb.String() +} diff --git a/agent/robot/executor/standard/delivery_center.go b/agent/robot/executor/standard/delivery_center.go new file mode 100644 index 00000000..0009d7db --- /dev/null +++ b/agent/robot/executor/standard/delivery_center.go @@ -0,0 +1,378 @@ +package standard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/yaoapp/gou/process" + robottypes "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/attachment" + "github.com/yaoapp/yao/messenger" + messengerTypes "github.com/yaoapp/yao/messenger/types" +) + +// DeliveryCenter handles routing delivery content to configured channels +// It decides which channels to use based on robot/user preferences and executes the delivery +type DeliveryCenter struct { + httpClient *http.Client +} + +// NewDeliveryCenter creates a new DeliveryCenter instance +func NewDeliveryCenter() *DeliveryCenter { + return &DeliveryCenter{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Deliver sends content to all configured channels based on preferences +// Returns results for each channel target and any error +func (dc *DeliveryCenter) Deliver( + ctx *robottypes.Context, + content *robottypes.DeliveryContent, + deliveryCtx *robottypes.DeliveryContext, + prefs *robottypes.DeliveryPreferences, + robotInstance *robottypes.Robot, +) ([]robottypes.ChannelResult, error) { + if content == nil { + return nil, fmt.Errorf("delivery content is nil") + } + if prefs == nil { + return nil, nil // No preferences = no delivery + } + + var results []robottypes.ChannelResult + var lastErr error + + // Process email targets + if prefs.Email != nil && prefs.Email.Enabled { + for _, target := range prefs.Email.Targets { + result := dc.sendEmail(ctx.Context, content, target, deliveryCtx, robotInstance) + results = append(results, result) + if !result.Success && lastErr == nil { + lastErr = fmt.Errorf("email delivery failed: %s", result.Error) + } + } + } + + // Process webhook targets + if prefs.Webhook != nil && prefs.Webhook.Enabled { + for _, target := range prefs.Webhook.Targets { + result := dc.postWebhook(ctx.Context, content, target, deliveryCtx) + results = append(results, result) + if !result.Success && lastErr == nil { + lastErr = fmt.Errorf("webhook delivery failed: %s", result.Error) + } + } + } + + // Process process targets + if prefs.Process != nil && prefs.Process.Enabled { + for _, target := range prefs.Process.Targets { + result := dc.callProcess(ctx.Context, content, target, deliveryCtx) + results = append(results, result) + if !result.Success && lastErr == nil { + lastErr = fmt.Errorf("process delivery failed: %s", result.Error) + } + } + } + + return results, lastErr +} + +// sendEmail sends delivery content to a single email target +func (dc *DeliveryCenter) sendEmail( + ctx context.Context, + content *robottypes.DeliveryContent, + target robottypes.EmailTarget, + deliveryCtx *robottypes.DeliveryContext, + robotInstance *robottypes.Robot, +) robottypes.ChannelResult { + now := time.Now() + + // Build target identifier from recipients + targetID := strings.Join(target.To, ",") + if targetID == "" { + targetID = "no-recipients" + } + + result := robottypes.ChannelResult{ + Type: robottypes.DeliveryEmail, + Target: targetID, + SentAt: &now, + } + + // Get messenger service + svc := messenger.Instance + if svc == nil { + result.Error = "messenger service not available" + return result + } + + // Build email message + msg := &messengerTypes.Message{ + To: target.To, + Subject: buildEmailSubject(target.Subject, target.Template, content, deliveryCtx), + Body: buildEmailBody(target.Template, content), + Type: messengerTypes.MessageTypeEmail, + } + + // Set From address from Robot's email (if configured) + if robotInstance != nil && robotInstance.RobotEmail != "" { + msg.From = robotInstance.RobotEmail + } + + // Convert attachments + attachments := convertAttachments(ctx, content.Attachments) + if len(attachments) > 0 { + msg.Attachments = attachments + } + + // Send email using global default channel + channel := robottypes.DefaultEmailChannel() + err := svc.Send(ctx, channel, msg) + if err != nil { + result.Error = err.Error() + return result + } + + result.Success = true + result.Recipients = target.To + + return result +} + +// postWebhook posts delivery content to a single webhook target +func (dc *DeliveryCenter) postWebhook( + ctx context.Context, + content *robottypes.DeliveryContent, + target robottypes.WebhookTarget, + deliveryCtx *robottypes.DeliveryContext, +) robottypes.ChannelResult { + now := time.Now() + result := robottypes.ChannelResult{ + Type: robottypes.DeliveryWebhook, + Target: target.URL, + SentAt: &now, + } + + // Build webhook payload + payload := map[string]interface{}{ + "event": "robot.delivery", + "timestamp": now.Format(time.RFC3339), + "execution_id": deliveryCtx.ExecutionID, + "member_id": deliveryCtx.MemberID, + "team_id": deliveryCtx.TeamID, + "trigger_type": deliveryCtx.TriggerType, + "content": map[string]interface{}{ + "summary": content.Summary, + "body": content.Body, + }, + } + + // Add attachments info (not the actual files) + if len(content.Attachments) > 0 { + attachmentInfo := make([]map[string]interface{}, 0, len(content.Attachments)) + for _, att := range content.Attachments { + attachmentInfo = append(attachmentInfo, map[string]interface{}{ + "title": att.Title, + "description": att.Description, + "task_id": att.TaskID, + "file": att.File, + }) + } + payload["attachments"] = attachmentInfo + } + + // Marshal payload + payloadBytes, err := json.Marshal(payload) + if err != nil { + result.Error = fmt.Sprintf("failed to marshal payload: %v", err) + return result + } + + // Build request + method := target.Method + if method == "" { + method = "POST" + } + + req, err := http.NewRequestWithContext(ctx, method, target.URL, bytes.NewReader(payloadBytes)) + if err != nil { + result.Error = fmt.Sprintf("failed to create request: %v", err) + return result + } + + req.Header.Set("Content-Type", "application/json") + + // Add custom headers + for key, value := range target.Headers { + req.Header.Set(key, value) + } + + // Add secret if configured (for signature verification) + if target.Secret != "" { + // TODO: Implement HMAC signature + req.Header.Set("X-Webhook-Secret", target.Secret) + } + + // Send request + resp, err := dc.httpClient.Do(req) + if err != nil { + result.Error = fmt.Sprintf("request failed: %v", err) + return result + } + defer resp.Body.Close() + + // Read response body + body, _ := io.ReadAll(resp.Body) + + // Check status code + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + result.Error = fmt.Sprintf("webhook returned status %d: %s", resp.StatusCode, string(body)) + return result + } + + result.Success = true + result.Details = map[string]interface{}{ + "status_code": resp.StatusCode, + "response": string(body), + } + + return result +} + +// callProcess calls a Yao Process with delivery content +func (dc *DeliveryCenter) callProcess( + ctx context.Context, + content *robottypes.DeliveryContent, + target robottypes.ProcessTarget, + deliveryCtx *robottypes.DeliveryContext, +) robottypes.ChannelResult { + now := time.Now() + result := robottypes.ChannelResult{ + Type: robottypes.DeliveryProcess, + Target: target.Process, + SentAt: &now, + } + + // Build args: DeliveryContent as first arg, then additional args + args := make([]interface{}, 0, 1+len(target.Args)) + args = append(args, map[string]interface{}{ + "content": map[string]interface{}{ + "summary": content.Summary, + "body": content.Body, + "attachments": content.Attachments, + }, + "context": map[string]interface{}{ + "execution_id": deliveryCtx.ExecutionID, + "member_id": deliveryCtx.MemberID, + "team_id": deliveryCtx.TeamID, + "trigger_type": deliveryCtx.TriggerType, + }, + }) + args = append(args, target.Args...) + + // Create and execute process + proc, err := process.Of(target.Process, args...) + if err != nil { + result.Error = fmt.Sprintf("failed to create process: %v", err) + return result + } + proc.Context = ctx + + err = proc.Execute() + if err != nil { + result.Error = err.Error() + return result + } + + result.Success = true + result.Details = proc.Value + + return result +} + +// buildEmailSubject builds the email subject line +func buildEmailSubject(subject, template string, content *robottypes.DeliveryContent, ctx *robottypes.DeliveryContext) string { + // Use explicit subject if provided + if subject != "" { + return subject + } + + // Use template-based subject if template is specified + // TODO: Implement template rendering + if template != "" { + return fmt.Sprintf("[Robot] %s", content.Summary) + } + + // Default: use summary + if content.Summary != "" { + return fmt.Sprintf("[Robot] %s", content.Summary) + } + + return fmt.Sprintf("[Robot] Execution %s Complete", ctx.ExecutionID) +} + +// buildEmailBody builds the email body content +func buildEmailBody(template string, content *robottypes.DeliveryContent) string { + // TODO: Implement template rendering + // For now, just use the body directly + if content.Body != "" { + return content.Body + } + return content.Summary +} + +// convertAttachments converts DeliveryAttachment to messenger Attachment format +func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAttachment) []messengerTypes.Attachment { + if len(attachments) == 0 { + return nil + } + + result := make([]messengerTypes.Attachment, 0, len(attachments)) + + for _, att := range attachments { + // Parse file wrapper: __:// + uploader, fileID, isWrapper := attachment.Parse(att.File) + if !isWrapper { + // Skip non-wrapper attachments + continue + } + + // Get file info from attachment manager + manager, ok := attachment.Managers[uploader] + if !ok { + continue + } + + info, err := manager.Info(ctx, fileID) + if err != nil { + continue + } + + // Read file content + content, err := manager.Read(ctx, fileID) + if err != nil { + continue + } + + // Build messenger attachment + msgAtt := messengerTypes.Attachment{ + Filename: info.Filename, + ContentType: info.ContentType, + Content: content, + } + + result = append(result, msgAtt) + } + + return result +} diff --git a/agent/robot/executor/standard/delivery_test.go b/agent/robot/executor/standard/delivery_test.go new file mode 100644 index 00000000..b2c612e3 --- /dev/null +++ b/agent/robot/executor/standard/delivery_test.go @@ -0,0 +1,1174 @@ +package standard_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/agent/robot/executor/standard" + "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/agent/testutils" +) + +// ============================================================================ +// P4 Delivery Phase Tests +// ============================================================================ + +func TestRunDeliveryBasic(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), testAuth()) + + t.Run("generates delivery content from execution results", func(t *testing.T) { + robot := createDeliveryTestRobot(t, "robot.delivery") + exec := createDeliveryTestExecution(robot) + + // Set up execution context with P0-P3 results + exec.Inspiration = &types.InspirationReport{ + Content: "Morning analysis suggests focus on Q4 review.", + } + exec.Goals = &types.Goals{ + Content: "## Goals\n1. Review Q4 data\n2. Generate summary report", + } + exec.Tasks = []types.Task{ + {ID: "task-001", ExecutorID: "experts.data-analyst", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted}, + {ID: "task-002", ExecutorID: "experts.summarizer", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted}, + } + exec.Results = []types.TaskResult{ + {TaskID: "task-001", Success: true, Duration: 1500, Output: map[string]interface{}{"total_sales": 1500000}}, + {TaskID: "task-002", Success: true, Duration: 800, Output: "Q4 sales exceeded expectations by 15%."}, + } + + // Run delivery phase + e := standard.New() + err := e.RunDelivery(ctx, exec, nil) + + require.NoError(t, err) + require.NotNil(t, exec.Delivery) + require.NotNil(t, exec.Delivery.Content) + assert.NotEmpty(t, exec.Delivery.Content.Summary) + assert.NotEmpty(t, exec.Delivery.Content.Body) + assert.True(t, exec.Delivery.Success) + }) + + t.Run("handles partial failure in results", func(t *testing.T) { + robot := createDeliveryTestRobot(t, "robot.delivery") + exec := createDeliveryTestExecution(robot) + + exec.Goals = &types.Goals{ + Content: "## Goals\n1. Analyze data\n2. Generate report", + } + exec.Tasks = []types.Task{ + {ID: "task-001", ExecutorID: "experts.data-analyst", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted}, + {ID: "task-002", ExecutorID: "experts.summarizer", ExecutorType: types.ExecutorAssistant, Status: types.TaskFailed}, + } + exec.Results = []types.TaskResult{ + {TaskID: "task-001", Success: true, Duration: 1500, Output: map[string]interface{}{"data": "analyzed"}}, + {TaskID: "task-002", Success: false, Duration: 500, Error: "Summarization failed: timeout"}, + } + + e := standard.New() + err := e.RunDelivery(ctx, exec, nil) + + require.NoError(t, err) + require.NotNil(t, exec.Delivery) + require.NotNil(t, exec.Delivery.Content) + + // Content should mention the failure + body := strings.ToLower(exec.Delivery.Content.Body) + hasFailureInfo := strings.Contains(body, "fail") || + strings.Contains(body, "error") || + strings.Contains(body, "partial") || + strings.Contains(body, "✗") + + assert.True(t, hasFailureInfo || exec.Delivery.Content.Summary != "", "should mention failure or have valid summary") + }) +} + +func TestRunDeliveryErrorHandling(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), testAuth()) + + t.Run("returns error when robot is nil", func(t *testing.T) { + exec := &types.Execution{ + ID: "test-exec-1", + TriggerType: types.TriggerClock, + } + // Don't set robot + + e := standard.New() + err := e.RunDelivery(ctx, exec, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "robot not found") + }) + + t.Run("returns error when agent not found", func(t *testing.T) { + robot := &types.Robot{ + MemberID: "test-robot-1", + TeamID: "test-team-1", + Config: &types.Config{ + Identity: &types.Identity{Role: "Test"}, + Resources: &types.Resources{ + Phases: map[types.Phase]string{ + types.PhaseDelivery: "non.existent.agent", + }, + }, + }, + } + exec := createDeliveryTestExecution(robot) + exec.Results = []types.TaskResult{ + {TaskID: "task-001", Success: true, Duration: 100}, + } + + e := standard.New() + err := e.RunDelivery(ctx, exec, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "call failed") + }) +} + +// ============================================================================ +// Delivery Center Tests +// ============================================================================ + +func TestDeliveryCenterWebhook(t *testing.T) { + t.Run("posts to webhook successfully", func(t *testing.T) { + // Create mock webhook server + var receivedPayload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&receivedPayload) + assert.NoError(t, err) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "received"}`)) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test delivery completed", + Body: "# Test Report\n\nThis is a test.", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL}, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.Equal(t, types.DeliveryWebhook, results[0].Type) + assert.Equal(t, server.URL, results[0].Target) + + // Verify payload structure + assert.Equal(t, "robot.delivery", receivedPayload["event"]) + assert.Equal(t, "exec-001", receivedPayload["execution_id"]) + assert.Equal(t, "member-001", receivedPayload["member_id"]) + contentMap := receivedPayload["content"].(map[string]interface{}) + assert.Equal(t, "Test delivery completed", contentMap["summary"]) + }) + + t.Run("handles webhook failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "internal error"}`)) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL}, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + assert.Error(t, err) // Should return error for failed delivery + require.Len(t, results, 1) + assert.False(t, results[0].Success) + assert.Contains(t, results[0].Error, "500") + }) + + t.Run("supports multiple webhook targets", func(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL + "/hook1"}, + {URL: server.URL + "/hook2"}, + {URL: server.URL + "/hook3"}, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 3) + assert.Equal(t, 3, callCount) + + for _, r := range results { + assert.True(t, r.Success) + } + }) + + t.Run("includes custom headers", func(t *testing.T) { + var receivedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + { + URL: server.URL, + Headers: map[string]string{ + "X-Custom-Header": "custom-value", + "Authorization": "Bearer test-token", + }, + }, + }, + }, + } + + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.Equal(t, "custom-value", receivedHeaders.Get("X-Custom-Header")) + assert.Equal(t, "Bearer test-token", receivedHeaders.Get("Authorization")) + }) +} + +func TestDeliveryCenterNoChannels(t *testing.T) { + t.Run("succeeds with no channels configured", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + // No preferences + results, err := center.Deliver(ctx, content, deliveryCtx, nil, nil) + + assert.NoError(t, err) + assert.Empty(t, results) + }) + + t.Run("succeeds with disabled channels", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: false, // Disabled + Targets: []types.WebhookTarget{ + {URL: "http://example.com"}, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + assert.NoError(t, err) + assert.Empty(t, results) + }) +} + +func TestDeliveryCenterMixedChannels(t *testing.T) { + t.Run("delivers to multiple channel types", func(t *testing.T) { + webhookCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookCalled = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL}, + }, + }, + // Email would fail without messenger setup, but webhook should succeed + } + + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + assert.True(t, webhookCalled) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + }) +} + +func TestDefaultEmailChannel(t *testing.T) { + t.Run("returns default email channel", func(t *testing.T) { + // Default should be "email" + assert.Equal(t, "email", types.DefaultEmailChannel()) + }) + + t.Run("can set custom email channel", func(t *testing.T) { + // Save original + original := types.DefaultEmailChannel() + defer types.SetDefaultEmailChannel(original) + + // Set custom channel + types.SetDefaultEmailChannel("custom-email") + assert.Equal(t, "custom-email", types.DefaultEmailChannel()) + }) + + t.Run("ignores empty channel", func(t *testing.T) { + // Save original and restore after test + original := types.DefaultEmailChannel() + defer types.SetDefaultEmailChannel(original) + + types.SetDefaultEmailChannel("") + assert.Equal(t, original, types.DefaultEmailChannel()) + }) +} + +func TestRobotEmailInDelivery(t *testing.T) { + t.Run("robot email is passed to delivery center", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + // Robot with email configured + robot := &types.Robot{ + MemberID: "robot-001", + RobotEmail: "robot@example.com", + } + + // Webhook to verify robot is passed (email would fail without messenger) + webhookCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookCalled = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL}, + }, + }, + } + + // Deliver with robot + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, robot) + + assert.True(t, webhookCalled) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + }) + + t.Run("robot email field is loaded from map", func(t *testing.T) { + data := map[string]interface{}{ + "member_id": "robot-001", + "team_id": "team-001", + "robot_email": "robot@example.com", + } + + robot, err := types.NewRobotFromMap(data) + require.NoError(t, err) + assert.Equal(t, "robot@example.com", robot.RobotEmail) + }) + + t.Run("robot email can be empty", func(t *testing.T) { + data := map[string]interface{}{ + "member_id": "robot-001", + "team_id": "team-001", + // robot_email not set + } + + robot, err := types.NewRobotFromMap(data) + require.NoError(t, err) + assert.Empty(t, robot.RobotEmail) + }) +} + +func TestFormatDeliveryInput(t *testing.T) { + formatter := standard.NewInputFormatter() + + t.Run("formats complete execution context", func(t *testing.T) { + robot := &types.Robot{ + MemberID: "test-robot", + Config: &types.Config{ + Identity: &types.Identity{ + Role: "Sales Analyst", + Duties: []string{"Analyze data", "Generate reports"}, + }, + }, + } + + startTime := time.Now().Add(-5 * time.Minute) + endTime := time.Now() + exec := &types.Execution{ + ID: "exec-123", + TriggerType: types.TriggerClock, + Status: types.ExecCompleted, + StartTime: startTime, + EndTime: &endTime, + Inspiration: &types.InspirationReport{ + Content: "Morning analysis suggests focus on Q4.", + }, + Goals: &types.Goals{ + Content: "## Goals\n1. Review Q4 data", + }, + Tasks: []types.Task{ + {ID: "task-001", ExecutorID: "data-analyst", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, ExpectedOutput: "JSON with sales data"}, + }, + Results: []types.TaskResult{ + {TaskID: "task-001", Success: true, Duration: 1500, Output: map[string]interface{}{"sales": 1000000}}, + }, + } + + result := formatter.FormatDeliveryInput(exec, robot) + + assert.Contains(t, result, "## Robot Identity") + assert.Contains(t, result, "Sales Analyst") + assert.Contains(t, result, "## Execution Context") + assert.Contains(t, result, "clock") + assert.Contains(t, result, "## Inspiration (P0)") + assert.Contains(t, result, "Morning analysis") + assert.Contains(t, result, "## Goals (P1)") + assert.Contains(t, result, "Review Q4 data") + assert.Contains(t, result, "## Tasks (P2)") + assert.Contains(t, result, "task-001") + assert.Contains(t, result, "## Results (P3)") + assert.Contains(t, result, "✓ Task: task-001") + }) + + t.Run("handles empty execution", func(t *testing.T) { + exec := &types.Execution{ + ID: "exec-empty", + TriggerType: types.TriggerHuman, + Status: types.ExecPending, + StartTime: time.Now(), + } + + result := formatter.FormatDeliveryInput(exec, nil) + + assert.Contains(t, result, "## Execution Context") + assert.Contains(t, result, "human") + }) +} + +// ============================================================================ +// Email Delivery Tests (requires messenger setup) +// ============================================================================ + +func TestDeliveryCenterEmail(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Set robot channel as default for tests + original := types.DefaultEmailChannel() + types.SetDefaultEmailChannel("robot") + defer types.SetDefaultEmailChannel(original) + + t.Run("sends email to single target", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test Delivery Report", + Body: "This is a test delivery from Robot Agent.\n\n## Results\n- Task 1: Completed\n- Task 2: Completed", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerClock, + TeamID: "team-001", + } + + robot := &types.Robot{ + MemberID: "robot-001", + RobotEmail: "robot@example.com", + } + + prefs := &types.DeliveryPreferences{ + Email: &types.EmailPreference{ + Enabled: true, + Targets: []types.EmailTarget{ + { + To: []string{"test@example.com"}, + Subject: "Robot Delivery Test", + }, + }, + }, + } + + // Send email - ignore billing/API errors, just verify the call was made + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, robot) + + require.Len(t, results, 1) + assert.Equal(t, types.DeliveryEmail, results[0].Type) + assert.Equal(t, "test@example.com", results[0].Target) + // Note: Success depends on messenger configuration + }) + + t.Run("sends email to multiple targets", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Multi-target Test", + Body: "Test body for multiple recipients", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-002", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + robot := &types.Robot{ + MemberID: "robot-001", + RobotEmail: "robot@example.com", + } + + prefs := &types.DeliveryPreferences{ + Email: &types.EmailPreference{ + Enabled: true, + Targets: []types.EmailTarget{ + { + To: []string{"user1@example.com"}, + Subject: "Report for User 1", + }, + { + To: []string{"user2@example.com", "user3@example.com"}, + Subject: "Report for Team", + }, + }, + }, + } + + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, robot) + + require.Len(t, results, 2) + assert.Equal(t, types.DeliveryEmail, results[0].Type) + assert.Equal(t, types.DeliveryEmail, results[1].Type) + assert.Equal(t, "user1@example.com", results[0].Target) + assert.Equal(t, "user2@example.com,user3@example.com", results[1].Target) + }) + + t.Run("sends email with attachments", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Report with Attachments", + Body: "Please find the attached report.", + Attachments: []types.DeliveryAttachment{ + { + Title: "Q4 Report.pdf", + Description: "Quarterly sales report", + TaskID: "task-001", + File: "__local://reports/q4-2024.pdf", + }, + { + Title: "Data Export.csv", + Description: "Raw data export", + TaskID: "task-002", + File: "__local://exports/data.csv", + }, + }, + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-003", + TriggerType: types.TriggerClock, + TeamID: "team-001", + } + + robot := &types.Robot{ + MemberID: "robot-001", + RobotEmail: "robot@example.com", + } + + prefs := &types.DeliveryPreferences{ + Email: &types.EmailPreference{ + Enabled: true, + Targets: []types.EmailTarget{ + { + To: []string{"manager@example.com"}, + Subject: "Weekly Report with Attachments", + }, + }, + }, + } + + // Send - attachment conversion may fail if files don't exist, but structure is tested + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, robot) + + require.Len(t, results, 1) + assert.Equal(t, types.DeliveryEmail, results[0].Type) + }) +} + +// ============================================================================ +// Process Delivery Tests (requires Yao process setup) +// ============================================================================ + +func TestDeliveryCenterProcess(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + t.Run("calls process with delivery content", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Process Test Summary", + Body: "This is the body content for process testing.", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-process-001", + TriggerType: types.TriggerEvent, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.tests.delivery.Handle", + }, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, types.DeliveryProcess, results[0].Type) + assert.Equal(t, "scripts.tests.delivery.Handle", results[0].Target) + assert.True(t, results[0].Success) + + // Verify process received correct data (Details structure depends on process return) + assert.NotNil(t, results[0].Details, "process should return details") + }) + + t.Run("calls process with additional args", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Args Test", + Body: "Testing additional arguments", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-process-002", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.tests.delivery.Handle", + Args: []interface{}{"custom-arg-1", "custom-arg-2"}, + }, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.NotNil(t, results[0].Details, "process should return details with args") + }) + + t.Run("calls multiple process targets", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Multi-process Test", + Body: "Testing multiple process targets", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-process-003", + TriggerType: types.TriggerClock, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.tests.delivery.Handle", + }, + { + Process: "scripts.tests.delivery.Notify", + Args: []interface{}{"user-123", "push"}, + }, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 2) + + // First process: Handle + assert.Equal(t, "scripts.tests.delivery.Handle", results[0].Target) + assert.True(t, results[0].Success) + + // Second process: Notify + assert.Equal(t, "scripts.tests.delivery.Notify", results[1].Target) + assert.True(t, results[1].Success) + }) + + t.Run("handles process failure gracefully", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Failure Test", + Body: "Testing process failure handling", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-process-004", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.tests.delivery.HandleWithFailure", + Args: []interface{}{true}, // shouldFail = true + }, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + assert.Error(t, err) + require.Len(t, results, 1) + assert.False(t, results[0].Success) + assert.Contains(t, results[0].Error, "Simulated process failure") + }) + + t.Run("handles process with attachments", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Attachment Process Test", + Body: "Testing process with attachments", + Attachments: []types.DeliveryAttachment{ + { + Title: "Report.pdf", + Description: "Test report", + TaskID: "task-001", + File: "__local://test/report.pdf", + }, + }, + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-process-005", + TriggerType: types.TriggerClock, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.tests.delivery.HandleAttachments", + }, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.NotNil(t, results[0].Details, "process should return details with attachments info") + }) +} + +// ============================================================================ +// Mixed Channel Tests +// ============================================================================ + +func TestDeliveryCenterAllChannels(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Set robot channel as default + original := types.DefaultEmailChannel() + types.SetDefaultEmailChannel("robot") + defer types.SetDefaultEmailChannel(original) + + t.Run("delivers to email, webhook, and process simultaneously", func(t *testing.T) { + // Setup webhook server + webhookCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookCalled = true + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Full Channel Test", + Body: "Testing all delivery channels together", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-full-001", + TriggerType: types.TriggerClock, + TeamID: "team-001", + } + + robot := &types.Robot{ + MemberID: "robot-001", + RobotEmail: "robot@example.com", + } + + prefs := &types.DeliveryPreferences{ + Email: &types.EmailPreference{ + Enabled: true, + Targets: []types.EmailTarget{ + {To: []string{"user@example.com"}, Subject: "Test"}, + }, + }, + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL}, + }, + }, + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + {Process: "scripts.tests.delivery.Handle"}, + }, + }, + } + + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, robot) + + // Should have 3 results (1 email + 1 webhook + 1 process) + require.Len(t, results, 3) + + // Verify each channel type + var emailResult, webhookResult, processResult *types.ChannelResult + for i := range results { + switch results[i].Type { + case types.DeliveryEmail: + emailResult = &results[i] + case types.DeliveryWebhook: + webhookResult = &results[i] + case types.DeliveryProcess: + processResult = &results[i] + } + } + + assert.NotNil(t, emailResult, "should have email result") + assert.NotNil(t, webhookResult, "should have webhook result") + assert.NotNil(t, processResult, "should have process result") + + // Webhook and process should succeed + assert.True(t, webhookCalled, "webhook should be called") + assert.True(t, webhookResult.Success, "webhook should succeed") + assert.True(t, processResult.Success, "process should succeed") + }) + + t.Run("partial failure does not stop other channels", func(t *testing.T) { + // Webhook that fails + failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer failServer.Close() + + // Webhook that succeeds + successCalled := false + successServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + successCalled = true + w.WriteHeader(http.StatusOK) + })) + defer successServer.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Partial Failure Test", + Body: "Testing partial failure handling", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-partial-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: failServer.URL}, // This will fail + {URL: successServer.URL}, // This should still be called + }, + }, + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + {Process: "scripts.tests.delivery.Handle"}, // This should succeed + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + // Should have error (from first webhook failure) + assert.Error(t, err) + + // But all targets should be attempted + require.Len(t, results, 3) + + // First webhook failed + assert.False(t, results[0].Success) + + // Second webhook and process should succeed + assert.True(t, successCalled, "second webhook should be called despite first failure") + assert.True(t, results[1].Success, "second webhook should succeed") + assert.True(t, results[2].Success, "process should succeed") + }) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +func createDeliveryTestRobot(t *testing.T, agentID string) *types.Robot { + t.Helper() + return &types.Robot{ + MemberID: "test-robot-1", + TeamID: "test-team-1", + DisplayName: "Test Robot", + Config: &types.Config{ + Identity: &types.Identity{ + Role: "Test Assistant", + Duties: []string{"Testing", "Validation"}, + }, + Resources: &types.Resources{ + Phases: map[types.Phase]string{ + types.PhaseDelivery: agentID, + }, + }, + }, + } +} + +func createDeliveryTestExecution(robot *types.Robot) *types.Execution { + exec := &types.Execution{ + ID: "test-exec-delivery-1", + MemberID: robot.MemberID, + TeamID: robot.TeamID, + TriggerType: types.TriggerClock, + StartTime: time.Now(), + Status: types.ExecRunning, + Phase: types.PhaseDelivery, + } + exec.SetRobot(robot) + return exec +} diff --git a/agent/robot/executor/standard/executor.go b/agent/robot/executor/standard/executor.go index 79e63fe6..c94430a5 100644 --- a/agent/robot/executor/standard/executor.go +++ b/agent/robot/executor/standard/executor.go @@ -7,16 +7,19 @@ import ( "github.com/yaoapp/yao/agent/robot/executor/types" "github.com/yaoapp/yao/agent/robot/job" + "github.com/yaoapp/yao/agent/robot/store" robottypes "github.com/yaoapp/yao/agent/robot/types" ) // Executor implements the standard executor with real Agent calls // This is the production executor that: // - Creates Job records for tracking +// - Persists execution history to database // - Calls real Agents via Assistant.Stream() // - Logs phase transitions and errors type Executor struct { config types.Config + store *store.ExecutionStore execCount atomic.Int32 currentCount atomic.Int32 onStart func() @@ -25,13 +28,16 @@ type Executor struct { // New creates a new standard executor func New() *Executor { - return &Executor{} + return &Executor{ + store: store.NewExecutionStore(), + } } // NewWithConfig creates a new standard executor with configuration func NewWithConfig(config types.Config) *Executor { return &Executor{ config: config, + store: store.NewExecutionStore(), } } @@ -76,6 +82,18 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri // Set robot reference for phase methods exec.SetRobot(robot) + // Persist execution record to database + // Robot is identified by member_id (globally unique in __yao.member table) + if !e.config.SkipPersistence && e.store != nil { + record := store.FromExecution(exec) + if err := e.store.Save(ctx.Context, record); err != nil { + // Log warning but don't fail execution + if !e.config.SkipJobIntegration { + _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist execution record: %v", err)) + } + } + } + // Acquire execution slot if !robot.TryAcquireSlot(exec) { if !e.config.SkipJobIntegration && exec.JobID != "" { @@ -105,6 +123,14 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update status to running: %v", err)) } } + // Persist running status + if !e.config.SkipPersistence && e.store != nil { + if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecRunning, ""); err != nil { + if !e.config.SkipJobIntegration { + _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist running status: %v", err)) + } + } + } // Check for simulated failure (for testing) if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" { @@ -113,6 +139,10 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri if !e.config.SkipJobIntegration { _ = job.FailExecution(ctx, exec, fmt.Errorf("simulated failure")) } + // Persist failed status + if !e.config.SkipPersistence && e.store != nil { + _ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, "simulated failure") + } return exec, nil } @@ -125,6 +155,10 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri if !e.config.SkipJobIntegration { _ = job.FailExecution(ctx, exec, err) } + // Persist failed status + if !e.config.SkipPersistence && e.store != nil { + _ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, err.Error()) + } return exec, nil } } @@ -139,6 +173,14 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to mark execution as completed: %v", err)) } } + // Persist completed status + if !e.config.SkipPersistence && e.store != nil { + if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecCompleted, ""); err != nil { + if !e.config.SkipJobIntegration { + _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist completed status: %v", err)) + } + } + } return exec, nil } @@ -183,6 +225,19 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, return err } + // Persist phase output to database + if !e.config.SkipPersistence && e.store != nil { + phaseData := e.getPhaseData(exec, phase) + if phaseData != nil { + if err := e.store.UpdatePhase(ctx.Context, exec.ID, phase, phaseData); err != nil { + // Log warning but don't fail execution + if !e.config.SkipJobIntegration { + _ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist phase %s data: %v", phase, err)) + } + } + } + } + if e.config.OnPhaseEnd != nil { e.config.OnPhaseEnd(phase) } @@ -195,6 +250,26 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, return nil } +// getPhaseData extracts the output data for a specific phase from execution +func (e *Executor) getPhaseData(exec *robottypes.Execution, phase robottypes.Phase) interface{} { + switch phase { + case robottypes.PhaseInspiration: + return exec.Inspiration + case robottypes.PhaseGoals: + return exec.Goals + case robottypes.PhaseTasks: + return exec.Tasks + case robottypes.PhaseRun: + return exec.Results + case robottypes.PhaseDelivery: + return exec.Delivery + case robottypes.PhaseLearning: + return exec.Learning + default: + return nil + } +} + // ExecCount returns total execution count func (e *Executor) ExecCount() int { return int(e.execCount.Load()) diff --git a/agent/robot/executor/standard/executor_test.go b/agent/robot/executor/standard/executor_test.go new file mode 100644 index 00000000..89ede60d --- /dev/null +++ b/agent/robot/executor/standard/executor_test.go @@ -0,0 +1,158 @@ +package standard_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/agent/robot/executor/standard" + "github.com/yaoapp/yao/agent/robot/executor/types" + "github.com/yaoapp/yao/agent/robot/store" + robottypes "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/agent/testutils" + oauthTypes "github.com/yaoapp/yao/openapi/oauth/types" +) + +// ============================================================================ +// Executor Persistence Integration Tests +// ============================================================================ + +func TestExecutorPersistence(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + t.Run("persists_execution_record_on_start", func(t *testing.T) { + ctx := robottypes.NewContext(context.Background(), &oauthTypes.AuthorizedInfo{ + UserID: "user_persist_001", + TeamID: "team_persist_001", + }) + + robot := createPersistenceTestRobot("member_persist_001", "team_persist_001") + + // Create executor with persistence enabled but skip job integration + e := standard.NewWithConfig(types.Config{ + SkipJobIntegration: true, + SkipPersistence: false, + }) + + // Execute with simulated failure to ensure we get a result + exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure") + require.NoError(t, err) + require.NotNil(t, exec) + + // Verify execution record was persisted + s := store.NewExecutionStore() + record, err := s.Get(context.Background(), exec.ID) + require.NoError(t, err) + require.NotNil(t, record) + + assert.Equal(t, exec.ID, record.ExecutionID) + assert.Equal(t, "member_persist_001", record.MemberID) + assert.Equal(t, "team_persist_001", record.TeamID) + assert.Equal(t, robottypes.TriggerHuman, record.TriggerType) + assert.Equal(t, robottypes.ExecFailed, record.Status) + assert.Equal(t, "simulated failure", record.Error) + + // Cleanup + _ = s.Delete(context.Background(), exec.ID) + }) + + t.Run("persists_failed_status_with_error", func(t *testing.T) { + ctx := robottypes.NewContext(context.Background(), &oauthTypes.AuthorizedInfo{ + UserID: "user_persist_002", + TeamID: "team_persist_002", + }) + + robot := createPersistenceTestRobot("member_persist_002", "team_persist_002") + + e := standard.NewWithConfig(types.Config{ + SkipJobIntegration: true, + SkipPersistence: false, + }) + + // Execute with simulated failure + exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure") + require.NoError(t, err) + require.NotNil(t, exec) + + // Verify the record has failed status with error message + s := store.NewExecutionStore() + record, err := s.Get(context.Background(), exec.ID) + require.NoError(t, err) + require.NotNil(t, record) + + assert.Equal(t, robottypes.ExecFailed, record.Status) + assert.Equal(t, "simulated failure", record.Error) + assert.NotNil(t, record.StartTime) + + // Cleanup + _ = s.Delete(context.Background(), exec.ID) + }) + + t.Run("skips_persistence_when_disabled", func(t *testing.T) { + ctx := robottypes.NewContext(context.Background(), &oauthTypes.AuthorizedInfo{ + UserID: "user_persist_003", + TeamID: "team_persist_003", + }) + + robot := createPersistenceTestRobot("member_persist_003", "team_persist_003") + + // Create executor with persistence disabled + e := standard.NewWithConfig(types.Config{ + SkipJobIntegration: true, + SkipPersistence: true, + }) + + exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure") + require.NoError(t, err) + require.NotNil(t, exec) + + // Verify no record was created + s := store.NewExecutionStore() + record, err := s.Get(context.Background(), exec.ID) + require.NoError(t, err) + assert.Nil(t, record) // Should not exist + }) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +func createPersistenceTestRobot(memberID, teamID string) *robottypes.Robot { + return &robottypes.Robot{ + MemberID: memberID, + TeamID: teamID, + DisplayName: "Persistence Test Robot", + Status: robottypes.RobotIdle, + AutonomousMode: true, + Config: &robottypes.Config{ + Identity: &robottypes.Identity{ + Role: "Test Robot", + Duties: []string{"Testing persistence"}, + }, + Quota: &robottypes.Quota{ + Max: 5, + Queue: 10, + }, + Triggers: &robottypes.Triggers{ + Intervene: &robottypes.TriggerSwitch{Enabled: true}, + }, + Resources: &robottypes.Resources{ + Phases: map[robottypes.Phase]string{ + robottypes.PhaseInspiration: "robot.inspiration", + robottypes.PhaseGoals: "robot.goals", + robottypes.PhaseTasks: "robot.tasks", + robottypes.PhaseRun: "robot.validation", + "validation": "robot.validation", + }, + Agents: []string{"experts.text-writer", "experts.data-analyst"}, + }, + }, + } +} diff --git a/agent/robot/executor/standard/goals.go b/agent/robot/executor/standard/goals.go index d7610c2b..5d531e0f 100644 --- a/agent/robot/executor/standard/goals.go +++ b/agent/robot/executor/standard/goals.go @@ -170,7 +170,7 @@ func ParseDelivery(data map[string]interface{}) *robottypes.DeliveryTarget { func IsValidDeliveryType(t robottypes.DeliveryType) bool { switch t { case robottypes.DeliveryEmail, robottypes.DeliveryWebhook, - robottypes.DeliveryFile, robottypes.DeliveryNotify: + robottypes.DeliveryProcess, robottypes.DeliveryNotify: return true default: return false diff --git a/agent/robot/executor/standard/goals_test.go b/agent/robot/executor/standard/goals_test.go index 8fcbb228..da34b84b 100644 --- a/agent/robot/executor/standard/goals_test.go +++ b/agent/robot/executor/standard/goals_test.go @@ -334,7 +334,7 @@ func TestParseDeliveryFromGoalsResponse(t *testing.T) { if exec.Goals.Delivery.Type != "" { validTypes := []types.DeliveryType{ types.DeliveryEmail, types.DeliveryWebhook, - types.DeliveryFile, types.DeliveryNotify, + types.DeliveryProcess, types.DeliveryNotify, } found := false for _, vt := range validTypes { @@ -355,7 +355,7 @@ func TestDeliveryTypeValidation(t *testing.T) { validTypes := []types.DeliveryType{ types.DeliveryEmail, types.DeliveryWebhook, - types.DeliveryFile, + types.DeliveryProcess, types.DeliveryNotify, } @@ -462,7 +462,7 @@ func TestParseDelivery(t *testing.T) { }) t.Run("parses all valid delivery types", func(t *testing.T) { - validTypes := []string{"email", "webhook", "file", "notify"} + validTypes := []string{"email", "webhook", "process", "notify"} for _, dt := range validTypes { data := map[string]interface{}{ diff --git a/agent/robot/executor/standard/input.go b/agent/robot/executor/standard/input.go index bcb4b023..d02d08dd 100644 --- a/agent/robot/executor/standard/input.go +++ b/agent/robot/executor/standard/input.go @@ -511,12 +511,17 @@ func (f *InputFormatter) FormatExecutionSummary(exec *robottypes.Execution) stri // 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.Content != nil { + sb.WriteString(fmt.Sprintf("- **Summary**: %s\n", exec.Delivery.Content.Summary)) + } if exec.Delivery.Success { sb.WriteString("- **Status**: ✓ Success\n") } else { sb.WriteString(fmt.Sprintf("- **Status**: ✗ Failed (%s)\n", exec.Delivery.Error)) } + if len(exec.Delivery.Results) > 0 { + sb.WriteString(fmt.Sprintf("- **Channels**: %d\n", len(exec.Delivery.Results))) + } sb.WriteString("\n") } diff --git a/agent/robot/executor/standard/input_test.go b/agent/robot/executor/standard/input_test.go index 60f2c748..57c4a1b6 100644 --- a/agent/robot/executor/standard/input_test.go +++ b/agent/robot/executor/standard/input_test.go @@ -455,7 +455,11 @@ func TestInputFormatterFormatExecutionSummary(t *testing.T) { {TaskID: "t2", Success: true, Duration: 200}, }, Delivery: &types.DeliveryResult{ - Type: types.DeliveryEmail, + RequestID: "test-delivery-001", + Content: &types.DeliveryContent{ + Summary: "Test delivery completed", + Body: "# Test Delivery\n\nTest delivery body.", + }, Success: true, }, } @@ -476,7 +480,7 @@ func TestInputFormatterFormatExecutionSummary(t *testing.T) { assert.Contains(t, result, "## Results (P3)") assert.Contains(t, result, "✓ t1") assert.Contains(t, result, "## Delivery (P4)") - assert.Contains(t, result, "email") + assert.Contains(t, result, "Test delivery completed") }) t.Run("formats execution with error", func(t *testing.T) { diff --git a/agent/robot/executor/types/types.go b/agent/robot/executor/types/types.go index d05b4c45..c2c3629c 100644 --- a/agent/robot/executor/types/types.go +++ b/agent/robot/executor/types/types.go @@ -53,6 +53,9 @@ type Config struct { // SkipJobIntegration skips job system integration (for testing) SkipJobIntegration bool + // SkipPersistence skips execution record persistence (for testing) + SkipPersistence bool + // OnPhaseStart callback when a phase starts OnPhaseStart func(phase robottypes.Phase) diff --git a/agent/robot/job/execution_test.go b/agent/robot/job/execution_test.go index 87a6e505..e63f082d 100644 --- a/agent/robot/job/execution_test.go +++ b/agent/robot/job/execution_test.go @@ -278,8 +278,12 @@ func TestCompleteExecution(t *testing.T) { // Simulate execution progress exec.Delivery = &types.DeliveryResult{ + RequestID: "test-delivery-001", + Content: &types.DeliveryContent{ + Summary: "Test delivery completed", + Body: "# Test Delivery\n\nThis is a test delivery result.", + }, Success: true, - Type: types.DeliveryEmail, } err = job.CompleteExecution(ctx, exec) diff --git a/agent/robot/store/execution.go b/agent/robot/store/execution.go new file mode 100644 index 00000000..3e97f7ba --- /dev/null +++ b/agent/robot/store/execution.go @@ -0,0 +1,707 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/yaoapp/gou/model" + "github.com/yaoapp/yao/agent/robot/types" +) + +// ExecutionRecord - persistent storage for robot execution history +// Maps to __yao.agent_execution model +type ExecutionRecord struct { + ID int64 `json:"id,omitempty"` // Auto-increment primary key + ExecutionID string `json:"execution_id"` // Unique execution identifier + MemberID string `json:"member_id"` // Robot member ID (globally unique) + TeamID string `json:"team_id"` // Team ID + JobID string `json:"job_id,omitempty"` // Linked job.Job ID + TriggerType types.TriggerType `json:"trigger_type"` // clock | human | event + + // Status tracking (synced with runtime Execution) + Status types.ExecStatus `json:"status"` // pending | running | completed | failed | cancelled + Phase types.Phase `json:"phase"` // Current phase + Current *CurrentState `json:"current,omitempty"` + Error string `json:"error,omitempty"` + + // Trigger input + Input *types.TriggerInput `json:"input,omitempty"` + + // Phase outputs (P0-P5) + Inspiration *types.InspirationReport `json:"inspiration,omitempty"` + Goals *types.Goals `json:"goals,omitempty"` + Tasks []types.Task `json:"tasks,omitempty"` + Results []types.TaskResult `json:"results,omitempty"` + Delivery *types.DeliveryResult `json:"delivery,omitempty"` + Learning []types.LearningEntry `json:"learning,omitempty"` + + // Timestamps + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// CurrentState - current executing state (for JSON storage) +type CurrentState struct { + TaskIndex int `json:"task_index"` // index in Tasks slice + Progress string `json:"progress,omitempty"` // human-readable progress (e.g., "2/5 tasks") +} + +// ListOptions - options for listing execution records +type ListOptions struct { + MemberID string `json:"member_id,omitempty"` // Filter by robot member ID + TeamID string `json:"team_id,omitempty"` + Status types.ExecStatus `json:"status,omitempty"` + TriggerType types.TriggerType `json:"trigger_type,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` + OrderBy string `json:"order_by,omitempty"` // e.g., "start_time desc" +} + +// ExecutionStore - persistent storage for robot execution records +type ExecutionStore struct { + modelID string +} + +// NewExecutionStore creates a new execution store instance +func NewExecutionStore() *ExecutionStore { + return &ExecutionStore{ + modelID: "__yao.agent.execution", + } +} + +// Save creates or updates an execution record +func (s *ExecutionStore) Save(ctx context.Context, record *ExecutionRecord) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + data := s.recordToMap(record) + + // Check if record exists by execution_id + existing, err := s.Get(ctx, record.ExecutionID) + if err == nil && existing != nil { + // Update existing record + _, err = mod.UpdateWhere( + model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "execution_id", Value: record.ExecutionID}, + }, + }, + data, + ) + if err != nil { + return fmt.Errorf("failed to update execution record: %w", err) + } + return nil + } + + // Create new record + _, err = mod.Create(data) + if err != nil { + return fmt.Errorf("failed to create execution record: %w", err) + } + return nil +} + +// Get retrieves an execution record by execution_id +func (s *ExecutionStore) Get(ctx context.Context, executionID string) (*ExecutionRecord, error) { + mod := model.Select(s.modelID) + if mod == nil { + return nil, fmt.Errorf("model %s not found", s.modelID) + } + + rows, err := mod.Get(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "execution_id", Value: executionID}, + }, + Limit: 1, + }) + if err != nil { + return nil, fmt.Errorf("failed to get execution record: %w", err) + } + + if len(rows) == 0 { + return nil, nil + } + + return s.mapToRecord(rows[0]) +} + +// List retrieves execution records with filters +func (s *ExecutionStore) List(ctx context.Context, opts *ListOptions) ([]*ExecutionRecord, error) { + mod := model.Select(s.modelID) + if mod == nil { + return nil, fmt.Errorf("model %s not found", s.modelID) + } + + params := model.QueryParam{} + + // Build where conditions + var wheres []model.QueryWhere + if opts != nil { + if opts.MemberID != "" { + wheres = append(wheres, model.QueryWhere{Column: "member_id", Value: opts.MemberID}) + } + if opts.TeamID != "" { + wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: opts.TeamID}) + } + if opts.Status != "" { + wheres = append(wheres, model.QueryWhere{Column: "status", Value: string(opts.Status)}) + } + if opts.TriggerType != "" { + wheres = append(wheres, model.QueryWhere{Column: "trigger_type", Value: string(opts.TriggerType)}) + } + + params.Limit = opts.Limit + if params.Limit == 0 { + params.Limit = 100 // default limit + } + + // Note: model.QueryParam doesn't have Offset, use Page instead + if opts.Offset > 0 && opts.Limit > 0 { + params.Page = (opts.Offset / opts.Limit) + 1 + } + + if opts.OrderBy != "" { + // Parse OrderBy: "column desc" or "column asc" or just "column" + parts := splitOrderBy(opts.OrderBy) + params.Orders = []model.QueryOrder{{Column: parts[0], Option: parts[1]}} + } else { + params.Orders = []model.QueryOrder{{Column: "start_time", Option: "desc"}} + } + } else { + params.Limit = 100 + params.Orders = []model.QueryOrder{{Column: "start_time", Option: "desc"}} + } + + params.Wheres = wheres + + rows, err := mod.Get(params) + if err != nil { + return nil, fmt.Errorf("failed to list execution records: %w", err) + } + + records := make([]*ExecutionRecord, 0, len(rows)) + for _, row := range rows { + record, err := s.mapToRecord(row) + if err != nil { + continue // skip invalid records + } + records = append(records, record) + } + + return records, nil +} + +// UpdatePhase updates the current phase and its data +func (s *ExecutionStore) UpdatePhase(ctx context.Context, executionID string, phase types.Phase, data interface{}) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + updateData := map[string]interface{}{ + "phase": string(phase), + } + + // Set the appropriate phase output field + switch phase { + case types.PhaseInspiration: + if data != nil { + updateData["inspiration"] = data + } + case types.PhaseGoals: + if data != nil { + updateData["goals"] = data + } + case types.PhaseTasks: + if data != nil { + updateData["tasks"] = data + } + case types.PhaseRun: + if data != nil { + updateData["results"] = data + } + case types.PhaseDelivery: + if data != nil { + updateData["delivery"] = data + } + case types.PhaseLearning: + if data != nil { + updateData["learning"] = data + } + } + + _, err := mod.UpdateWhere( + model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "execution_id", Value: executionID}, + }, + }, + updateData, + ) + if err != nil { + return fmt.Errorf("failed to update phase: %w", err) + } + + return nil +} + +// UpdateStatus updates the execution status +func (s *ExecutionStore) UpdateStatus(ctx context.Context, executionID string, status types.ExecStatus, errorMsg string) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + updateData := map[string]interface{}{ + "status": string(status), + } + + if errorMsg != "" { + updateData["error"] = errorMsg + } + + // Set end_time for terminal states + if status == types.ExecCompleted || status == types.ExecFailed || status == types.ExecCancelled { + now := time.Now() + updateData["end_time"] = now + } + + _, err := mod.UpdateWhere( + model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "execution_id", Value: executionID}, + }, + }, + updateData, + ) + if err != nil { + return fmt.Errorf("failed to update status: %w", err) + } + + return nil +} + +// UpdateCurrent updates the current executing state +func (s *ExecutionStore) UpdateCurrent(ctx context.Context, executionID string, current *CurrentState) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + updateData := map[string]interface{}{ + "current": current, + } + + _, err := mod.UpdateWhere( + model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "execution_id", Value: executionID}, + }, + }, + updateData, + ) + if err != nil { + return fmt.Errorf("failed to update current state: %w", err) + } + + return nil +} + +// Delete removes an execution record by execution_id +func (s *ExecutionStore) Delete(ctx context.Context, executionID string) error { + mod := model.Select(s.modelID) + if mod == nil { + return fmt.Errorf("model %s not found", s.modelID) + } + + _, err := mod.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "execution_id", Value: executionID}, + }, + }) + if err != nil { + return fmt.Errorf("failed to delete execution record: %w", err) + } + + return nil +} + +// recordToMap converts ExecutionRecord to map for model operations +func (s *ExecutionStore) recordToMap(record *ExecutionRecord) map[string]interface{} { + data := map[string]interface{}{ + "execution_id": record.ExecutionID, + "member_id": record.MemberID, + "team_id": record.TeamID, + "trigger_type": string(record.TriggerType), + "status": string(record.Status), + "phase": string(record.Phase), + } + + if record.JobID != "" { + data["job_id"] = record.JobID + } + if record.Error != "" { + data["error"] = record.Error + } + if record.Current != nil { + data["current"] = record.Current + } + if record.Input != nil { + data["input"] = record.Input + } + if record.Inspiration != nil { + data["inspiration"] = record.Inspiration + } + if record.Goals != nil { + data["goals"] = record.Goals + } + if record.Tasks != nil { + data["tasks"] = record.Tasks + } + if record.Results != nil { + data["results"] = record.Results + } + if record.Delivery != nil { + data["delivery"] = record.Delivery + } + if record.Learning != nil { + data["learning"] = record.Learning + } + if record.StartTime != nil { + data["start_time"] = *record.StartTime + } + if record.EndTime != nil { + data["end_time"] = *record.EndTime + } + + return data +} + +// mapToRecord converts a model row to ExecutionRecord +func (s *ExecutionStore) mapToRecord(row map[string]interface{}) (*ExecutionRecord, error) { + record := &ExecutionRecord{} + + // Basic fields + if v, ok := row["id"]; ok { + switch id := v.(type) { + case float64: + record.ID = int64(id) + case int64: + record.ID = id + case int: + record.ID = int64(id) + } + } + if v, ok := row["execution_id"].(string); ok { + record.ExecutionID = v + } + if v, ok := row["member_id"].(string); ok { + record.MemberID = v + } + if v, ok := row["team_id"].(string); ok { + record.TeamID = v + } + if v, ok := row["job_id"].(string); ok { + record.JobID = v + } + if v, ok := row["trigger_type"].(string); ok { + record.TriggerType = types.TriggerType(v) + } + if v, ok := row["status"].(string); ok { + record.Status = types.ExecStatus(v) + } + if v, ok := row["phase"].(string); ok { + record.Phase = types.Phase(v) + } + if v, ok := row["error"].(string); ok { + record.Error = v + } + + // JSON fields - need to unmarshal + if v := row["current"]; v != nil { + record.Current = s.parseCurrentState(v) + } + if v := row["input"]; v != nil { + record.Input = s.parseTriggerInput(v) + } + if v := row["inspiration"]; v != nil { + record.Inspiration = s.parseInspirationReport(v) + } + if v := row["goals"]; v != nil { + record.Goals = s.parseGoals(v) + } + if v := row["tasks"]; v != nil { + record.Tasks = s.parseTasks(v) + } + if v := row["results"]; v != nil { + record.Results = s.parseResults(v) + } + if v := row["delivery"]; v != nil { + record.Delivery = s.parseDeliveryResult(v) + } + if v := row["learning"]; v != nil { + record.Learning = s.parseLearningEntries(v) + } + + // Timestamps + if v := row["start_time"]; v != nil { + record.StartTime = s.parseTime(v) + } + if v := row["end_time"]; v != nil { + record.EndTime = s.parseTime(v) + } + if v := row["created_at"]; v != nil { + record.CreatedAt = s.parseTime(v) + } + if v := row["updated_at"]; v != nil { + record.UpdatedAt = s.parseTime(v) + } + + return record, nil +} + +// Helper functions for parsing JSON fields + +func (s *ExecutionStore) parseCurrentState(v interface{}) *CurrentState { + data, err := s.toJSON(v) + if err != nil { + return nil + } + var state CurrentState + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + return &state +} + +func (s *ExecutionStore) parseTriggerInput(v interface{}) *types.TriggerInput { + data, err := s.toJSON(v) + if err != nil { + return nil + } + var input types.TriggerInput + if err := json.Unmarshal(data, &input); err != nil { + return nil + } + return &input +} + +func (s *ExecutionStore) parseInspirationReport(v interface{}) *types.InspirationReport { + data, err := s.toJSON(v) + if err != nil { + return nil + } + var report types.InspirationReport + if err := json.Unmarshal(data, &report); err != nil { + return nil + } + return &report +} + +func (s *ExecutionStore) parseGoals(v interface{}) *types.Goals { + data, err := s.toJSON(v) + if err != nil { + return nil + } + var goals types.Goals + if err := json.Unmarshal(data, &goals); err != nil { + return nil + } + return &goals +} + +func (s *ExecutionStore) parseTasks(v interface{}) []types.Task { + data, err := s.toJSON(v) + if err != nil { + return nil + } + var tasks []types.Task + if err := json.Unmarshal(data, &tasks); err != nil { + return nil + } + return tasks +} + +func (s *ExecutionStore) parseResults(v interface{}) []types.TaskResult { + data, err := s.toJSON(v) + if err != nil { + return nil + } + var results []types.TaskResult + if err := json.Unmarshal(data, &results); err != nil { + return nil + } + return results +} + +func (s *ExecutionStore) parseDeliveryResult(v interface{}) *types.DeliveryResult { + data, err := s.toJSON(v) + if err != nil { + return nil + } + var result types.DeliveryResult + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + return &result +} + +func (s *ExecutionStore) parseLearningEntries(v interface{}) []types.LearningEntry { + data, err := s.toJSON(v) + if err != nil { + return nil + } + var entries []types.LearningEntry + if err := json.Unmarshal(data, &entries); err != nil { + return nil + } + return entries +} + +func (s *ExecutionStore) toJSON(v interface{}) ([]byte, error) { + switch data := v.(type) { + case []byte: + return data, nil + case string: + return []byte(data), nil + case map[string]interface{}, []interface{}: + return json.Marshal(data) + default: + return json.Marshal(v) + } +} + +// splitOrderBy parses "column desc" or "column asc" or just "column" +// Returns [column, option] where option defaults to "desc" +func splitOrderBy(orderBy string) [2]string { + parts := [2]string{"", "desc"} + if orderBy == "" { + return parts + } + + // Split by space + for i, c := range orderBy { + if c == ' ' { + parts[0] = orderBy[:i] + rest := orderBy[i+1:] + if rest == "asc" || rest == "ASC" { + parts[1] = "asc" + } else if rest == "desc" || rest == "DESC" { + parts[1] = "desc" + } + return parts + } + } + + // No space found, just column name + parts[0] = orderBy + return parts +} + +func (s *ExecutionStore) parseTime(v interface{}) *time.Time { + switch t := v.(type) { + case time.Time: + return &t + case *time.Time: + return t + case string: + // Try parsing common time formats + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + } + for _, format := range formats { + if parsed, err := time.Parse(format, t); err == nil { + return &parsed + } + } + } + return nil +} + +// FromExecution creates an ExecutionRecord from a runtime Execution +func FromExecution(exec *types.Execution) *ExecutionRecord { + record := &ExecutionRecord{ + ExecutionID: exec.ID, + MemberID: exec.MemberID, + TeamID: exec.TeamID, + JobID: exec.JobID, + TriggerType: exec.TriggerType, + Status: exec.Status, + Phase: exec.Phase, + Error: exec.Error, + Input: exec.Input, + Inspiration: exec.Inspiration, + Goals: exec.Goals, + Tasks: exec.Tasks, + Results: exec.Results, + Delivery: exec.Delivery, + Learning: exec.Learning, + } + + // Convert timestamps + if !exec.StartTime.IsZero() { + record.StartTime = &exec.StartTime + } + if exec.EndTime != nil { + record.EndTime = exec.EndTime + } + + // Convert CurrentState + if exec.Current != nil { + record.Current = &CurrentState{ + TaskIndex: exec.Current.TaskIndex, + Progress: exec.Current.Progress, + } + } + + return record +} + +// ToExecution converts an ExecutionRecord to a runtime Execution +func (r *ExecutionRecord) ToExecution() *types.Execution { + exec := &types.Execution{ + ID: r.ExecutionID, + MemberID: r.MemberID, + TeamID: r.TeamID, + JobID: r.JobID, + TriggerType: r.TriggerType, + Status: r.Status, + Phase: r.Phase, + Error: r.Error, + Input: r.Input, + Inspiration: r.Inspiration, + Goals: r.Goals, + Tasks: r.Tasks, + Results: r.Results, + Delivery: r.Delivery, + Learning: r.Learning, + } + + // Convert timestamps + if r.StartTime != nil { + exec.StartTime = *r.StartTime + } + if r.EndTime != nil { + exec.EndTime = r.EndTime + } + + // Convert CurrentState + if r.Current != nil { + exec.Current = &types.CurrentState{ + TaskIndex: r.Current.TaskIndex, + Progress: r.Current.Progress, + } + } + + return exec +} diff --git a/agent/robot/store/execution_test.go b/agent/robot/store/execution_test.go new file mode 100644 index 00000000..39aa25be --- /dev/null +++ b/agent/robot/store/execution_test.go @@ -0,0 +1,768 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/gou/model" + "github.com/yaoapp/yao/agent/robot/store" + "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/agent/testutils" +) + +// TestExecutionStoreSave tests creating and updating execution records +func TestExecutionStoreSave(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Clean up any existing test data + cleanupTestExecutions(t) + defer cleanupTestExecutions(t) + + s := store.NewExecutionStore() + ctx := context.Background() + + t.Run("creates_new_execution_record", func(t *testing.T) { + startTime := time.Now() + record := &store.ExecutionRecord{ + ExecutionID: "exec_test_save_001", + MemberID: "member_test_001", + TeamID: "team_test_001", + JobID: "job_test_001", + TriggerType: types.TriggerClock, + Status: types.ExecPending, + Phase: types.PhaseInspiration, + StartTime: &startTime, + } + + err := s.Save(ctx, record) + require.NoError(t, err) + + // Verify it was created + saved, err := s.Get(ctx, "exec_test_save_001") + require.NoError(t, err) + require.NotNil(t, saved) + + assert.Equal(t, "exec_test_save_001", saved.ExecutionID) + assert.Equal(t, "member_test_001", saved.MemberID) + assert.Equal(t, "team_test_001", saved.TeamID) + assert.Equal(t, "job_test_001", saved.JobID) + assert.Equal(t, types.TriggerClock, saved.TriggerType) + assert.Equal(t, types.ExecPending, saved.Status) + assert.Equal(t, types.PhaseInspiration, saved.Phase) + assert.NotNil(t, saved.StartTime) + assert.NotNil(t, saved.CreatedAt) + }) + + t.Run("updates_existing_execution_record", func(t *testing.T) { + // First create a record + startTime := time.Now() + record := &store.ExecutionRecord{ + ExecutionID: "exec_test_save_002", + MemberID: "member_test_002", + TeamID: "team_test_002", + TriggerType: types.TriggerHuman, + Status: types.ExecPending, + Phase: types.PhaseInspiration, + StartTime: &startTime, + } + + err := s.Save(ctx, record) + require.NoError(t, err) + + // Update the record + record.Status = types.ExecRunning + record.Phase = types.PhaseGoals + record.Goals = &types.Goals{Content: "Test goals content"} + + err = s.Save(ctx, record) + require.NoError(t, err) + + // Verify the update + saved, err := s.Get(ctx, "exec_test_save_002") + require.NoError(t, err) + require.NotNil(t, saved) + + assert.Equal(t, types.ExecRunning, saved.Status) + assert.Equal(t, types.PhaseGoals, saved.Phase) + assert.NotNil(t, saved.Goals) + assert.Equal(t, "Test goals content", saved.Goals.Content) + }) +} + +// TestExecutionStoreGet tests retrieving execution records +func TestExecutionStoreGet(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestExecutions(t) + defer cleanupTestExecutions(t) + + s := store.NewExecutionStore() + ctx := context.Background() + + // Create a test record with all fields populated + setupTestExecution(t, s, ctx) + + t.Run("returns_existing_record", func(t *testing.T) { + record, err := s.Get(ctx, "exec_test_get_001") + require.NoError(t, err) + require.NotNil(t, record) + + assert.Equal(t, "exec_test_get_001", record.ExecutionID) + assert.Equal(t, "member_test_get", record.MemberID) + assert.Equal(t, "team_test_get", record.TeamID) + assert.Equal(t, types.TriggerClock, record.TriggerType) + assert.Equal(t, types.ExecCompleted, record.Status) + assert.Equal(t, types.PhaseDelivery, record.Phase) + + // Verify phase outputs + assert.NotNil(t, record.Inspiration) + assert.Equal(t, "Test inspiration content", record.Inspiration.Content) + assert.NotNil(t, record.Goals) + assert.Equal(t, "Test goals content", record.Goals.Content) + assert.Len(t, record.Tasks, 2) + assert.Equal(t, "task_001", record.Tasks[0].ID) + assert.Len(t, record.Results, 2) + assert.True(t, record.Results[0].Success) + }) + + t.Run("returns_nil_for_non_existent_record", func(t *testing.T) { + record, err := s.Get(ctx, "exec_non_existent") + require.NoError(t, err) + assert.Nil(t, record) + }) +} + +// TestExecutionStoreList tests listing execution records with filters +func TestExecutionStoreList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestExecutions(t) + defer cleanupTestExecutions(t) + + s := store.NewExecutionStore() + ctx := context.Background() + + // Create multiple test records + setupTestExecutionsForList(t, s, ctx) + + t.Run("lists_all_records_without_filters", func(t *testing.T) { + records, err := s.List(ctx, nil) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(records), 4) + }) + + t.Run("filters_by_member_id", func(t *testing.T) { + records, err := s.List(ctx, &store.ListOptions{ + MemberID: "member_list_001", + }) + require.NoError(t, err) + assert.Equal(t, 2, len(records)) + for _, r := range records { + assert.Equal(t, "member_list_001", r.MemberID) + } + }) + + t.Run("filters_by_team_id", func(t *testing.T) { + records, err := s.List(ctx, &store.ListOptions{ + TeamID: "team_list_001", + }) + require.NoError(t, err) + assert.Equal(t, 3, len(records)) + for _, r := range records { + assert.Equal(t, "team_list_001", r.TeamID) + } + }) + + t.Run("filters_by_status", func(t *testing.T) { + records, err := s.List(ctx, &store.ListOptions{ + Status: types.ExecCompleted, + }) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(records), 2) + for _, r := range records { + assert.Equal(t, types.ExecCompleted, r.Status) + } + }) + + t.Run("filters_by_trigger_type", func(t *testing.T) { + records, err := s.List(ctx, &store.ListOptions{ + TriggerType: types.TriggerHuman, + }) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(records), 1) + for _, r := range records { + assert.Equal(t, types.TriggerHuman, r.TriggerType) + } + }) + + t.Run("respects_limit", func(t *testing.T) { + records, err := s.List(ctx, &store.ListOptions{ + Limit: 2, + }) + require.NoError(t, err) + assert.Equal(t, 2, len(records)) + }) + + t.Run("combines_multiple_filters", func(t *testing.T) { + records, err := s.List(ctx, &store.ListOptions{ + TeamID: "team_list_001", + Status: types.ExecCompleted, + }) + require.NoError(t, err) + assert.Equal(t, 2, len(records)) + for _, r := range records { + assert.Equal(t, "team_list_001", r.TeamID) + assert.Equal(t, types.ExecCompleted, r.Status) + } + }) +} + +// TestExecutionStoreUpdatePhase tests updating phase and phase data +func TestExecutionStoreUpdatePhase(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestExecutions(t) + defer cleanupTestExecutions(t) + + s := store.NewExecutionStore() + ctx := context.Background() + + // Create a base record + startTime := time.Now() + record := &store.ExecutionRecord{ + ExecutionID: "exec_test_phase_001", + MemberID: "member_phase_001", + TeamID: "team_phase_001", + TriggerType: types.TriggerClock, + Status: types.ExecRunning, + Phase: types.PhaseInspiration, + StartTime: &startTime, + } + err := s.Save(ctx, record) + require.NoError(t, err) + + t.Run("updates_inspiration_phase", func(t *testing.T) { + inspiration := &types.InspirationReport{ + Content: "Updated inspiration content", + } + err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseInspiration, inspiration) + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_phase_001") + require.NoError(t, err) + assert.Equal(t, types.PhaseInspiration, saved.Phase) + assert.NotNil(t, saved.Inspiration) + assert.Equal(t, "Updated inspiration content", saved.Inspiration.Content) + }) + + t.Run("updates_goals_phase", func(t *testing.T) { + goals := &types.Goals{ + Content: "Updated goals content", + } + err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseGoals, goals) + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_phase_001") + require.NoError(t, err) + assert.Equal(t, types.PhaseGoals, saved.Phase) + assert.NotNil(t, saved.Goals) + assert.Equal(t, "Updated goals content", saved.Goals.Content) + }) + + t.Run("updates_tasks_phase", func(t *testing.T) { + tasks := []types.Task{ + {ID: "task_phase_001", ExecutorType: types.ExecutorAssistant}, + {ID: "task_phase_002", ExecutorType: types.ExecutorProcess}, + } + err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseTasks, tasks) + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_phase_001") + require.NoError(t, err) + assert.Equal(t, types.PhaseTasks, saved.Phase) + assert.Len(t, saved.Tasks, 2) + assert.Equal(t, "task_phase_001", saved.Tasks[0].ID) + }) + + t.Run("updates_run_phase", func(t *testing.T) { + results := []types.TaskResult{ + {TaskID: "task_phase_001", Success: true, Output: "Result 1"}, + {TaskID: "task_phase_002", Success: false, Error: "Failed"}, + } + err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseRun, results) + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_phase_001") + require.NoError(t, err) + assert.Equal(t, types.PhaseRun, saved.Phase) + assert.Len(t, saved.Results, 2) + assert.True(t, saved.Results[0].Success) + assert.False(t, saved.Results[1].Success) + }) + + t.Run("updates_delivery_phase", func(t *testing.T) { + delivery := &types.DeliveryResult{ + Success: true, + } + err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseDelivery, delivery) + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_phase_001") + require.NoError(t, err) + assert.Equal(t, types.PhaseDelivery, saved.Phase) + assert.NotNil(t, saved.Delivery) + assert.True(t, saved.Delivery.Success) + }) + + t.Run("updates_learning_phase", func(t *testing.T) { + learning := []types.LearningEntry{ + {Type: types.LearnExecution, Content: "Learned something"}, + } + err := s.UpdatePhase(ctx, "exec_test_phase_001", types.PhaseLearning, learning) + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_phase_001") + require.NoError(t, err) + assert.Equal(t, types.PhaseLearning, saved.Phase) + assert.Len(t, saved.Learning, 1) + assert.Equal(t, "Learned something", saved.Learning[0].Content) + }) +} + +// TestExecutionStoreUpdateStatus tests updating execution status +func TestExecutionStoreUpdateStatus(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestExecutions(t) + defer cleanupTestExecutions(t) + + s := store.NewExecutionStore() + ctx := context.Background() + + t.Run("updates_status_to_running", func(t *testing.T) { + startTime := time.Now() + record := &store.ExecutionRecord{ + ExecutionID: "exec_test_status_001", + MemberID: "member_status_001", + TeamID: "team_status_001", + TriggerType: types.TriggerClock, + Status: types.ExecPending, + Phase: types.PhaseInspiration, + StartTime: &startTime, + } + err := s.Save(ctx, record) + require.NoError(t, err) + + err = s.UpdateStatus(ctx, "exec_test_status_001", types.ExecRunning, "") + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_status_001") + require.NoError(t, err) + assert.Equal(t, types.ExecRunning, saved.Status) + assert.Nil(t, saved.EndTime) // Should not set end_time for running + }) + + t.Run("updates_status_to_completed_with_end_time", func(t *testing.T) { + startTime := time.Now() + record := &store.ExecutionRecord{ + ExecutionID: "exec_test_status_002", + MemberID: "member_status_002", + TeamID: "team_status_002", + TriggerType: types.TriggerHuman, + Status: types.ExecRunning, + Phase: types.PhaseDelivery, + StartTime: &startTime, + } + err := s.Save(ctx, record) + require.NoError(t, err) + + err = s.UpdateStatus(ctx, "exec_test_status_002", types.ExecCompleted, "") + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_status_002") + require.NoError(t, err) + assert.Equal(t, types.ExecCompleted, saved.Status) + assert.NotNil(t, saved.EndTime) // Should set end_time for completed + }) + + t.Run("updates_status_to_failed_with_error", func(t *testing.T) { + startTime := time.Now() + record := &store.ExecutionRecord{ + ExecutionID: "exec_test_status_003", + MemberID: "member_status_003", + TeamID: "team_status_003", + TriggerType: types.TriggerEvent, + Status: types.ExecRunning, + Phase: types.PhaseRun, + StartTime: &startTime, + } + err := s.Save(ctx, record) + require.NoError(t, err) + + err = s.UpdateStatus(ctx, "exec_test_status_003", types.ExecFailed, "Task execution failed: timeout") + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_status_003") + require.NoError(t, err) + assert.Equal(t, types.ExecFailed, saved.Status) + assert.Equal(t, "Task execution failed: timeout", saved.Error) + assert.NotNil(t, saved.EndTime) // Should set end_time for failed + }) + + t.Run("updates_status_to_cancelled", func(t *testing.T) { + startTime := time.Now() + record := &store.ExecutionRecord{ + ExecutionID: "exec_test_status_004", + MemberID: "member_status_004", + TeamID: "team_status_004", + TriggerType: types.TriggerClock, + Status: types.ExecRunning, + Phase: types.PhaseTasks, + StartTime: &startTime, + } + err := s.Save(ctx, record) + require.NoError(t, err) + + err = s.UpdateStatus(ctx, "exec_test_status_004", types.ExecCancelled, "User cancelled") + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_status_004") + require.NoError(t, err) + assert.Equal(t, types.ExecCancelled, saved.Status) + assert.Equal(t, "User cancelled", saved.Error) + assert.NotNil(t, saved.EndTime) // Should set end_time for cancelled + }) +} + +// TestExecutionStoreUpdateCurrent tests updating current state +func TestExecutionStoreUpdateCurrent(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestExecutions(t) + defer cleanupTestExecutions(t) + + s := store.NewExecutionStore() + ctx := context.Background() + + // Create a base record + startTime := time.Now() + record := &store.ExecutionRecord{ + ExecutionID: "exec_test_current_001", + MemberID: "member_current_001", + TeamID: "team_current_001", + TriggerType: types.TriggerClock, + Status: types.ExecRunning, + Phase: types.PhaseRun, + StartTime: &startTime, + } + err := s.Save(ctx, record) + require.NoError(t, err) + + t.Run("updates_current_state", func(t *testing.T) { + current := &store.CurrentState{ + TaskIndex: 2, + Progress: "3/5 tasks completed", + } + err := s.UpdateCurrent(ctx, "exec_test_current_001", current) + require.NoError(t, err) + + saved, err := s.Get(ctx, "exec_test_current_001") + require.NoError(t, err) + assert.NotNil(t, saved.Current) + assert.Equal(t, 2, saved.Current.TaskIndex) + assert.Equal(t, "3/5 tasks completed", saved.Current.Progress) + }) +} + +// TestExecutionStoreDelete tests deleting execution records +func TestExecutionStoreDelete(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestExecutions(t) + defer cleanupTestExecutions(t) + + s := store.NewExecutionStore() + ctx := context.Background() + + t.Run("deletes_existing_record", func(t *testing.T) { + // Create a record + startTime := time.Now() + record := &store.ExecutionRecord{ + ExecutionID: "exec_test_delete_001", + MemberID: "member_delete_001", + TeamID: "team_delete_001", + TriggerType: types.TriggerClock, + Status: types.ExecCompleted, + Phase: types.PhaseDelivery, + StartTime: &startTime, + } + err := s.Save(ctx, record) + require.NoError(t, err) + + // Verify it exists + saved, err := s.Get(ctx, "exec_test_delete_001") + require.NoError(t, err) + require.NotNil(t, saved) + + // Delete it + err = s.Delete(ctx, "exec_test_delete_001") + require.NoError(t, err) + + // Verify it's gone + saved, err = s.Get(ctx, "exec_test_delete_001") + require.NoError(t, err) + assert.Nil(t, saved) + }) + + t.Run("no_error_for_non_existent_record", func(t *testing.T) { + err := s.Delete(ctx, "exec_non_existent") + assert.NoError(t, err) + }) +} + +// TestExecutionRecordConversion tests conversion between ExecutionRecord and Execution +func TestExecutionRecordConversion(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + t.Run("converts_from_execution", func(t *testing.T) { + now := time.Now() + endTime := now.Add(time.Hour) + exec := &types.Execution{ + ID: "exec_convert_001", + MemberID: "member_convert_001", + TeamID: "team_convert_001", + JobID: "job_convert_001", + TriggerType: types.TriggerHuman, + Status: types.ExecCompleted, + Phase: types.PhaseDelivery, + StartTime: now, + EndTime: &endTime, + Error: "", + Inspiration: &types.InspirationReport{Content: "Test inspiration"}, + Goals: &types.Goals{Content: "Test goals"}, + Tasks: []types.Task{ + {ID: "task_001", ExecutorType: types.ExecutorAssistant}, + }, + Results: []types.TaskResult{ + {TaskID: "task_001", Success: true}, + }, + Current: &types.CurrentState{ + TaskIndex: 1, + Progress: "1/1 tasks", + }, + } + + record := store.FromExecution(exec) + + assert.Equal(t, "exec_convert_001", record.ExecutionID) + assert.Equal(t, "member_convert_001", record.MemberID) + assert.Equal(t, "team_convert_001", record.TeamID) + assert.Equal(t, "job_convert_001", record.JobID) + assert.Equal(t, types.TriggerHuman, record.TriggerType) + assert.Equal(t, types.ExecCompleted, record.Status) + assert.Equal(t, types.PhaseDelivery, record.Phase) + assert.NotNil(t, record.StartTime) + assert.NotNil(t, record.EndTime) + assert.NotNil(t, record.Inspiration) + assert.NotNil(t, record.Goals) + assert.Len(t, record.Tasks, 1) + assert.Len(t, record.Results, 1) + assert.NotNil(t, record.Current) + assert.Equal(t, 1, record.Current.TaskIndex) + }) + + t.Run("converts_to_execution", func(t *testing.T) { + now := time.Now() + endTime := now.Add(time.Hour) + record := &store.ExecutionRecord{ + ExecutionID: "exec_convert_002", + MemberID: "member_convert_002", + TeamID: "team_convert_002", + JobID: "job_convert_002", + TriggerType: types.TriggerClock, + Status: types.ExecRunning, + Phase: types.PhaseRun, + StartTime: &now, + EndTime: &endTime, + Inspiration: &types.InspirationReport{Content: "Test inspiration"}, + Goals: &types.Goals{Content: "Test goals"}, + Tasks: []types.Task{ + {ID: "task_002", ExecutorType: types.ExecutorProcess}, + }, + Results: []types.TaskResult{ + {TaskID: "task_002", Success: false, Error: "Failed"}, + }, + Current: &store.CurrentState{ + TaskIndex: 0, + Progress: "0/1 tasks", + }, + } + + exec := record.ToExecution() + + assert.Equal(t, "exec_convert_002", exec.ID) + assert.Equal(t, "member_convert_002", exec.MemberID) + assert.Equal(t, "team_convert_002", exec.TeamID) + assert.Equal(t, "job_convert_002", exec.JobID) + assert.Equal(t, types.TriggerClock, exec.TriggerType) + assert.Equal(t, types.ExecRunning, exec.Status) + assert.Equal(t, types.PhaseRun, exec.Phase) + assert.NotNil(t, exec.Inspiration) + assert.NotNil(t, exec.Goals) + assert.Len(t, exec.Tasks, 1) + assert.Len(t, exec.Results, 1) + assert.NotNil(t, exec.Current) + assert.Equal(t, 0, exec.Current.TaskIndex) + }) +} + +// Helper functions + +func cleanupTestExecutions(t *testing.T) { + mod := model.Select("__yao.agent.execution") + if mod == nil { + return + } + + // Delete all test execution records + _, err := mod.DeleteWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "execution_id", OP: "like", Value: "exec_test_%"}, + }, + }) + if err != nil { + t.Logf("Warning: failed to cleanup test executions: %v", err) + } +} + +func setupTestExecution(t *testing.T, s *store.ExecutionStore, ctx context.Context) { + startTime := time.Now().Add(-time.Hour) + endTime := time.Now() + + record := &store.ExecutionRecord{ + ExecutionID: "exec_test_get_001", + MemberID: "member_test_get", + TeamID: "team_test_get", + JobID: "job_test_get", + TriggerType: types.TriggerClock, + Status: types.ExecCompleted, + Phase: types.PhaseDelivery, + StartTime: &startTime, + EndTime: &endTime, + Inspiration: &types.InspirationReport{ + Content: "Test inspiration content", + }, + Goals: &types.Goals{ + Content: "Test goals content", + }, + Tasks: []types.Task{ + {ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted}, + {ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskCompleted}, + }, + Results: []types.TaskResult{ + {TaskID: "task_001", Success: true, Output: "Result 1"}, + {TaskID: "task_002", Success: true, Output: "Result 2"}, + }, + Delivery: &types.DeliveryResult{ + Success: true, + }, + Learning: []types.LearningEntry{ + {Type: types.LearnExecution, Content: "Test learning"}, + }, + } + + err := s.Save(ctx, record) + require.NoError(t, err) +} + +func setupTestExecutionsForList(t *testing.T, s *store.ExecutionStore, ctx context.Context) { + startTime := time.Now() + + records := []*store.ExecutionRecord{ + { + ExecutionID: "exec_test_list_001", + MemberID: "member_list_001", + TeamID: "team_list_001", + TriggerType: types.TriggerClock, + Status: types.ExecCompleted, + Phase: types.PhaseDelivery, + StartTime: &startTime, + }, + { + ExecutionID: "exec_test_list_002", + MemberID: "member_list_001", + TeamID: "team_list_001", + TriggerType: types.TriggerClock, + Status: types.ExecCompleted, + Phase: types.PhaseDelivery, + StartTime: &startTime, + }, + { + ExecutionID: "exec_test_list_003", + MemberID: "member_list_002", + TeamID: "team_list_001", + TriggerType: types.TriggerHuman, + Status: types.ExecRunning, + Phase: types.PhaseRun, + StartTime: &startTime, + }, + { + ExecutionID: "exec_test_list_004", + MemberID: "member_list_002", + TeamID: "team_list_002", + TriggerType: types.TriggerEvent, + Status: types.ExecFailed, + Phase: types.PhaseRun, + StartTime: &startTime, + Error: "Test error", + }, + } + + for _, record := range records { + err := s.Save(ctx, record) + require.NoError(t, err) + } +} diff --git a/agent/robot/types/config.go b/agent/robot/types/config.go index 77148828..78bbb656 100644 --- a/agent/robot/types/config.go +++ b/agent/robot/types/config.go @@ -7,17 +7,17 @@ import ( // Config - robot_config in __yao.member type Config struct { - Triggers *Triggers `json:"triggers,omitempty"` - Clock *Clock `json:"clock,omitempty"` - Identity *Identity `json:"identity"` - Quota *Quota `json:"quota,omitempty"` - KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant) - DB *DB `json:"db,omitempty"` // shared database (same as assistant) - Learn *Learn `json:"learn,omitempty"` // learning config for private KB - Resources *Resources `json:"resources,omitempty"` - Delivery *Delivery `json:"delivery,omitempty"` - Events []Event `json:"events,omitempty"` - Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings + Triggers *Triggers `json:"triggers,omitempty"` + Clock *Clock `json:"clock,omitempty"` + Identity *Identity `json:"identity"` + Quota *Quota `json:"quota,omitempty"` + KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant) + DB *DB `json:"db,omitempty"` // shared database (same as assistant) + Learn *Learn `json:"learn,omitempty"` // learning config for private KB + Resources *Resources `json:"resources,omitempty"` + Delivery *DeliveryPreferences `json:"delivery,omitempty"` // delivery preferences (see robot.go) + Events []Event `json:"events,omitempty"` + Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings } // ExecutorConfig - executor settings @@ -224,12 +224,6 @@ type MCPConfig struct { Tools []string `json:"tools,omitempty"` // empty = all } -// Delivery - output delivery -type Delivery struct { - Type DeliveryType `json:"type"` - Opts map[string]interface{} `json:"opts,omitempty"` -} - // Event - event trigger config type Event struct { Type EventSource `json:"type"` // webhook | database diff --git a/agent/robot/types/config_global.go b/agent/robot/types/config_global.go new file mode 100644 index 00000000..e5989c53 --- /dev/null +++ b/agent/robot/types/config_global.go @@ -0,0 +1,35 @@ +package types + +import "sync" + +// Global configuration for robot agent +// These values can be set during agent initialization + +var ( + // defaultEmailChannel - default messenger channel name for sending emails + // Can be configured via SetDefaultEmailChannel() + // Default: "email" (maps to messengers/channels.yao configuration) + defaultEmailChannel = "email" + + // configMu protects global configuration + configMu sync.RWMutex +) + +// DefaultEmailChannel returns the default email channel name +func DefaultEmailChannel() string { + configMu.RLock() + defer configMu.RUnlock() + return defaultEmailChannel +} + +// SetDefaultEmailChannel sets the default messenger channel for email delivery +// This should be called during agent initialization +// The channel name must match a channel defined in messengers/channels.yao +func SetDefaultEmailChannel(channel string) { + if channel == "" { + return + } + configMu.Lock() + defer configMu.Unlock() + defaultEmailChannel = channel +} diff --git a/agent/robot/types/enums.go b/agent/robot/types/enums.go index a3ba2a1b..a42cae1c 100644 --- a/agent/robot/types/enums.go +++ b/agent/robot/types/enums.go @@ -111,10 +111,10 @@ type DeliveryType string // DeliveryType constants define the output delivery types const ( - DeliveryEmail DeliveryType = "email" - DeliveryFile DeliveryType = "file" - DeliveryWebhook DeliveryType = "webhook" - DeliveryNotify DeliveryType = "notify" + DeliveryEmail DeliveryType = "email" // Send via yao/messenger + DeliveryWebhook DeliveryType = "webhook" // POST to external URL + DeliveryProcess DeliveryType = "process" // Call Yao Process + DeliveryNotify DeliveryType = "notify" // In-app notification (future, auto by subscriptions) ) // DedupResult - deduplication result diff --git a/agent/robot/types/enums_test.go b/agent/robot/types/enums_test.go index 2791d4bd..5def46ff 100644 --- a/agent/robot/types/enums_test.go +++ b/agent/robot/types/enums_test.go @@ -83,8 +83,8 @@ func TestPriorityEnum(t *testing.T) { func TestDeliveryTypeEnum(t *testing.T) { assert.Equal(t, types.DeliveryType("email"), types.DeliveryEmail) - assert.Equal(t, types.DeliveryType("file"), types.DeliveryFile) assert.Equal(t, types.DeliveryType("webhook"), types.DeliveryWebhook) + assert.Equal(t, types.DeliveryType("process"), types.DeliveryProcess) assert.Equal(t, types.DeliveryType("notify"), types.DeliveryNotify) } diff --git a/agent/robot/types/robot.go b/agent/robot/types/robot.go index 19c4b41d..d84b94bb 100644 --- a/agent/robot/types/robot.go +++ b/agent/robot/types/robot.go @@ -20,6 +20,7 @@ type Robot struct { SystemPrompt string `json:"system_prompt"` Status RobotStatus `json:"robot_status"` AutonomousMode bool `json:"autonomous_mode"` + RobotEmail string `json:"robot_email"` // Robot's email address for sending emails // Parsed config (from robot_config JSON field) Config *Config `json:"-"` @@ -262,15 +263,100 @@ type ValidationResult struct { ReplyContent string `json:"reply_content,omitempty"` // content for next turn (if NeedReply) } -// DeliveryResult - P4 delivery output +// DeliveryResult - P4 delivery output (new architecture) type DeliveryResult struct { - Type DeliveryType `json:"type"` - Success bool `json:"success"` - Recipients []string `json:"recipients,omitempty"` // who received - Content string `json:"content,omitempty"` // formatted content that was delivered - Details interface{} `json:"details,omitempty"` // channel-specific response - Error string `json:"error,omitempty"` - SentAt *time.Time `json:"sent_at,omitempty"` + RequestID string `json:"request_id"` // Delivery request ID + Content *DeliveryContent `json:"content"` // Agent-generated content + Results []ChannelResult `json:"results,omitempty"` // Results per channel + Success bool `json:"success"` // Overall success + Error string `json:"error,omitempty"` // Error if failed + SentAt *time.Time `json:"sent_at,omitempty"` // When delivery completed +} + +// DeliveryContent - Content generated by Delivery Agent (only content, no channels) +type DeliveryContent struct { + Summary string `json:"summary"` // Brief 1-2 sentence summary + Body string `json:"body"` // Full markdown report + Attachments []DeliveryAttachment `json:"attachments,omitempty"` // Output artifacts from P3 +} + +// DeliveryAttachment - Task output attachment with metadata +type DeliveryAttachment struct { + Title string `json:"title"` // Human-readable title + Description string `json:"description,omitempty"` // What this artifact is + TaskID string `json:"task_id,omitempty"` // Which task produced this + File string `json:"file"` // Wrapper: __:// +} + +// DeliveryRequest - pushed to Delivery Center (no channels - center decides based on preferences) +type DeliveryRequest struct { + Content *DeliveryContent `json:"content"` // Agent-generated content + Context *DeliveryContext `json:"context"` // Tracking info +} + +// DeliveryContext - tracking and audit info +type DeliveryContext struct { + MemberID string `json:"member_id"` // Robot member ID (globally unique) + ExecutionID string `json:"execution_id"` // Execution ID + TriggerType TriggerType `json:"trigger_type"` // clock | human | event + TeamID string `json:"team_id"` // Team ID +} + +// DeliveryPreferences - Robot/User delivery preferences (from Config) +type DeliveryPreferences struct { + Email *EmailPreference `json:"email,omitempty"` // Email delivery settings + Webhook *WebhookPreference `json:"webhook,omitempty"` // Webhook delivery settings + Process *ProcessPreference `json:"process,omitempty"` // Process delivery settings +} + +// EmailPreference - Email delivery configuration +type EmailPreference struct { + Enabled bool `json:"enabled"` // Whether email delivery is enabled + Targets []EmailTarget `json:"targets,omitempty"` // Multiple email targets +} + +// EmailTarget - Single email target +type EmailTarget struct { + To []string `json:"to"` // Recipient addresses + Template string `json:"template,omitempty"` // Email template ID + Subject string `json:"subject,omitempty"` // Subject template +} + +// WebhookPreference - Webhook delivery configuration +type WebhookPreference struct { + Enabled bool `json:"enabled"` // Whether webhook delivery is enabled + Targets []WebhookTarget `json:"targets,omitempty"` // Multiple webhook targets +} + +// WebhookTarget - Single webhook target +type WebhookTarget struct { + URL string `json:"url"` // Webhook URL + Method string `json:"method,omitempty"` // HTTP method (default: POST) + Headers map[string]string `json:"headers,omitempty"` // Custom headers + Secret string `json:"secret,omitempty"` // Signing secret +} + +// ProcessPreference - Process delivery configuration +type ProcessPreference struct { + Enabled bool `json:"enabled"` // Whether process delivery is enabled + Targets []ProcessTarget `json:"targets,omitempty"` // Multiple process targets +} + +// ProcessTarget - Single process target +type ProcessTarget struct { + Process string `json:"process"` // Yao Process name + Args []any `json:"args,omitempty"` // Process arguments +} + +// ChannelResult - Result of delivery to a single channel target +type ChannelResult struct { + Type DeliveryType `json:"type"` // email | webhook | process + Target string `json:"target"` // Target identifier (email, URL, process name) + Success bool `json:"success"` // Whether delivery succeeded + Recipients []string `json:"recipients,omitempty"` // Who received (for email) + Details interface{} `json:"details,omitempty"` // Channel-specific response + Error string `json:"error,omitempty"` // Error message if failed + SentAt *time.Time `json:"sent_at,omitempty"` // When this target was delivered } // LearningEntry - knowledge to save @@ -297,6 +383,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) { DisplayName: getString(m, "display_name"), SystemPrompt: getString(m, "system_prompt"), AutonomousMode: getBool(m, "autonomous_mode"), + RobotEmail: getString(m, "robot_email"), } // Parse robot_status diff --git a/agent/robot/types/robot_test.go b/agent/robot/types/robot_test.go index 71c84395..dad84d12 100644 --- a/agent/robot/types/robot_test.go +++ b/agent/robot/types/robot_test.go @@ -536,25 +536,231 @@ func TestValidationResultMultiTurnFields(t *testing.T) { func TestDeliveryResultStructure(t *testing.T) { sentAt := time.Now() delivery := &types.DeliveryResult{ - Type: types.DeliveryEmail, - Success: true, - Recipients: []string{"user@example.com", "manager@example.com"}, - Content: "# Weekly Report\n\nSales increased by 20%...", - Details: map[string]interface{}{ - "message_id": "msg-12345", - "subject": "Daily Report", + RequestID: "req-12345", + Content: &types.DeliveryContent{ + Summary: "Weekly sales report completed", + Body: "# Weekly Report\n\nSales increased by 20%...", + Attachments: []types.DeliveryAttachment{ + { + Title: "Sales Report", + Description: "Detailed sales analysis", + TaskID: "task-1", + File: "__s3://report-12345.pdf", + }, + }, }, - SentAt: &sentAt, + Results: []types.ChannelResult{ + { + Type: types.DeliveryEmail, + Target: "user@example.com", + Success: true, + Details: map[string]interface{}{ + "message_id": "msg-12345", + }, + }, + { + Type: types.DeliveryWebhook, + Target: "https://webhook.example.com/notify", + Success: true, + }, + }, + Success: true, + SentAt: &sentAt, } - assert.Equal(t, types.DeliveryEmail, delivery.Type) + assert.Equal(t, "req-12345", delivery.RequestID) assert.True(t, delivery.Success) - assert.Len(t, delivery.Recipients, 2) - assert.Contains(t, delivery.Content, "Weekly Report") - assert.NotNil(t, delivery.Details) + assert.NotNil(t, delivery.Content) + assert.Equal(t, "Weekly sales report completed", delivery.Content.Summary) + assert.Contains(t, delivery.Content.Body, "Weekly Report") + assert.Len(t, delivery.Content.Attachments, 1) + assert.Equal(t, "__s3://report-12345.pdf", delivery.Content.Attachments[0].File) + assert.Len(t, delivery.Results, 2) + assert.Equal(t, types.DeliveryEmail, delivery.Results[0].Type) assert.NotNil(t, delivery.SentAt) } +func TestDeliveryContentStructure(t *testing.T) { + content := &types.DeliveryContent{ + Summary: "Task execution completed successfully", + Body: "# Execution Report\n\n## Summary\n- 3 tasks completed\n- 1 task failed", + Attachments: []types.DeliveryAttachment{ + { + Title: "Analysis Results", + Description: "JSON data from analysis task", + TaskID: "task-analysis", + File: "__local://files/analysis-result.json", + }, + { + Title: "Generated Chart", + TaskID: "task-chart", + File: "__s3://charts/sales-chart.png", + }, + }, + } + + assert.NotEmpty(t, content.Summary) + assert.Contains(t, content.Body, "Execution Report") + assert.Len(t, content.Attachments, 2) + assert.Equal(t, "Analysis Results", content.Attachments[0].Title) + assert.Equal(t, "task-analysis", content.Attachments[0].TaskID) +} + +func TestDeliveryAttachmentStructure(t *testing.T) { + attachment := &types.DeliveryAttachment{ + Title: "Sales Report PDF", + Description: "Monthly sales analysis report", + TaskID: "task-report", + File: "__s3://reports/sales-2024-01.pdf", + } + + assert.Equal(t, "Sales Report PDF", attachment.Title) + assert.Equal(t, "Monthly sales analysis report", attachment.Description) + assert.Equal(t, "task-report", attachment.TaskID) + assert.Contains(t, attachment.File, "__s3://") +} + +func TestDeliveryRequestStructure(t *testing.T) { + request := &types.DeliveryRequest{ + Content: &types.DeliveryContent{ + Summary: "Report ready", + Body: "# Report\n\nDetails...", + }, + Context: &types.DeliveryContext{ + MemberID: "member-123", + ExecutionID: "exec-456", + TriggerType: types.TriggerClock, + TeamID: "team-789", + }, + } + + assert.NotNil(t, request.Content) + assert.NotNil(t, request.Context) + assert.Equal(t, "member-123", request.Context.MemberID) + assert.Equal(t, "exec-456", request.Context.ExecutionID) + assert.Equal(t, types.TriggerClock, request.Context.TriggerType) +} + +func TestDeliveryPreferencesStructure(t *testing.T) { + prefs := &types.DeliveryPreferences{ + Email: &types.EmailPreference{ + Enabled: true, + Targets: []types.EmailTarget{ + { + To: []string{"team@example.com"}, + Template: "weekly-report", + Subject: "Weekly Report - {{.Date}}", + }, + { + To: []string{"backup@example.com"}, + }, + }, + }, + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + { + URL: "https://api.example.com/webhook", + Method: "POST", + Headers: map[string]string{ + "X-API-Key": "secret-key", + }, + Secret: "signing-secret", + }, + }, + }, + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.notify.slack", + Args: []any{"#general", "Report ready"}, + }, + }, + }, + } + + // Email + assert.True(t, prefs.Email.Enabled) + assert.Len(t, prefs.Email.Targets, 2) + assert.Equal(t, "weekly-report", prefs.Email.Targets[0].Template) + assert.Len(t, prefs.Email.Targets[0].To, 1) + + // Webhook + assert.True(t, prefs.Webhook.Enabled) + assert.Len(t, prefs.Webhook.Targets, 1) + assert.Equal(t, "https://api.example.com/webhook", prefs.Webhook.Targets[0].URL) + assert.Equal(t, "POST", prefs.Webhook.Targets[0].Method) + + // Process + assert.True(t, prefs.Process.Enabled) + assert.Len(t, prefs.Process.Targets, 1) + assert.Equal(t, "scripts.notify.slack", prefs.Process.Targets[0].Process) + assert.Len(t, prefs.Process.Targets[0].Args, 2) +} + +func TestChannelResultStructure(t *testing.T) { + t.Run("email result with recipients", func(t *testing.T) { + sentAt := time.Now() + result := &types.ChannelResult{ + Type: types.DeliveryEmail, + Target: "user@example.com", + Success: true, + Recipients: []string{"user@example.com", "manager@example.com"}, + Details: map[string]interface{}{ + "message_id": "msg-123", + }, + SentAt: &sentAt, + } + assert.Equal(t, types.DeliveryEmail, result.Type) + assert.Equal(t, "user@example.com", result.Target) + assert.True(t, result.Success) + assert.Len(t, result.Recipients, 2) + assert.NotNil(t, result.SentAt) + }) + + t.Run("webhook result", func(t *testing.T) { + sentAt := time.Now() + result := &types.ChannelResult{ + Type: types.DeliveryWebhook, + Target: "https://api.example.com/webhook", + Success: true, + Details: map[string]interface{}{ + "status_code": 200, + "response": "OK", + }, + SentAt: &sentAt, + } + assert.Equal(t, types.DeliveryWebhook, result.Type) + assert.True(t, result.Success) + assert.NotNil(t, result.SentAt) + }) + + t.Run("process result", func(t *testing.T) { + result := &types.ChannelResult{ + Type: types.DeliveryProcess, + Target: "scripts.notify.slack", + Success: true, + Details: map[string]interface{}{ + "output": "Message sent", + }, + } + assert.Equal(t, types.DeliveryProcess, result.Type) + assert.Equal(t, "scripts.notify.slack", result.Target) + }) + + t.Run("failed result", func(t *testing.T) { + result := &types.ChannelResult{ + Type: types.DeliveryWebhook, + Target: "https://api.example.com/webhook", + Success: false, + Error: "Connection refused", + } + assert.False(t, result.Success) + assert.Equal(t, "Connection refused", result.Error) + }) +} + func TestDeliveryTargetStructure(t *testing.T) { delivery := &types.DeliveryTarget{ Type: types.DeliveryEmail, diff --git a/data/bindata.go b/data/bindata.go index 966a0782..25e395c7 100644 --- a/data/bindata.go +++ b/data/bindata.go @@ -131,6 +131,7 @@ // .tmp/data/yao/langs/zh-hk/logins/user.login.yml // .tmp/data/yao/models/agent/assistant.mod.yao // .tmp/data/yao/models/agent/chat.mod.yao +// .tmp/data/yao/models/agent/execution.mod.yao // .tmp/data/yao/models/agent/message.mod.yao // .tmp/data/yao/models/agent/resume.mod.yao // .tmp/data/yao/models/agent/search.mod.yao @@ -343,7 +344,7 @@ func cuiSetupIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/setup/index.html", size: 10, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "cui/setup/index.html", size: 10, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -363,7 +364,7 @@ func cuiV09IndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v0.9/index.html", size: 13, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "cui/v0.9/index.html", size: 13, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -383,7 +384,7 @@ func cuiV10IndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v1.0/index.html", size: 49, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "cui/v1.0/index.html", size: 49, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -403,7 +404,7 @@ func cuiV10Layouts__indexAsyncJs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v1.0/layouts__index.async.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "cui/v1.0/layouts__index.async.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -423,7 +424,7 @@ func cuiV10UmiJs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "cui/v1.0/umi.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "cui/v1.0/umi.js", size: 71, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -443,7 +444,7 @@ func initEnv() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.env", size: 219, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.env", size: 219, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -463,7 +464,7 @@ func initVscodeSettingsJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/settings.json", size: 4666, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/settings.json", size: 4666, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -483,7 +484,7 @@ func initVscodeTypesRuntimeConsoleDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/console.d.ts", size: 221, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/console.d.ts", size: 221, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -503,7 +504,7 @@ func initVscodeTypesRuntimeExceptionDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/exception.d.ts", size: 738, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/exception.d.ts", size: 738, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -523,7 +524,7 @@ func initVscodeTypesRuntimeFsDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/fs.d.ts", size: 8554, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/fs.d.ts", size: 8554, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -543,7 +544,7 @@ func initVscodeTypesRuntimeGlobalDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/global.d.ts", size: 1759, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/global.d.ts", size: 1759, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -563,7 +564,7 @@ func initVscodeTypesRuntimeHttpDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/http.d.ts", size: 6179, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/http.d.ts", size: 6179, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -583,7 +584,7 @@ func initVscodeTypesRuntimeIoDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/io.d.ts", size: 587, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/io.d.ts", size: 587, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -603,7 +604,7 @@ func initVscodeTypesRuntimeLogDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/log.d.ts", size: 1692, mode: os.FileMode(493), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/log.d.ts", size: 1692, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -623,7 +624,7 @@ func initVscodeTypesRuntimeNeoDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/neo.d.ts", size: 3750, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/neo.d.ts", size: 3750, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -643,7 +644,7 @@ func initVscodeTypesRuntimeProcessFsDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process/fs.d.ts", size: 11133, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process/fs.d.ts", size: 11133, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -663,7 +664,7 @@ func initVscodeTypesRuntimeProcessHttpDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process/http.d.ts", size: 5653, mode: os.FileMode(493), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process/http.d.ts", size: 5653, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -683,7 +684,7 @@ func initVscodeTypesRuntimeProcessModelDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process/model.d.ts", size: 6656, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process/model.d.ts", size: 6656, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -703,7 +704,7 @@ func initVscodeTypesRuntimeProcessDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/process.d.ts", size: 23165, mode: os.FileMode(493), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/process.d.ts", size: 23165, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -723,7 +724,7 @@ func initVscodeTypesRuntimeQueryDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/query.d.ts", size: 6124, mode: os.FileMode(493), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/query.d.ts", size: 6124, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -743,7 +744,7 @@ func initVscodeTypesRuntimeStoreDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/store.d.ts", size: 2251, mode: os.FileMode(493), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/store.d.ts", size: 2251, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -763,7 +764,7 @@ func initVscodeTypesRuntimeSuiDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/sui.d.ts", size: 1713, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/sui.d.ts", size: 1713, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -783,7 +784,7 @@ func initVscodeTypesRuntimeTimeDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime/time.d.ts", size: 711, mode: os.FileMode(493), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime/time.d.ts", size: 711, mode: os.FileMode(493), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -803,7 +804,7 @@ func initVscodeTypesRuntimeDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/runtime.d.ts", size: 424, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/runtime.d.ts", size: 424, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -823,7 +824,7 @@ func initVscodeTypesSuiDTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/.vscode/types/sui.d.ts", size: 8931, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/.vscode/types/sui.d.ts", size: 8931, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -843,7 +844,7 @@ func initAppYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/app.yao", size: 3115, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/app.yao", size: 3115, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -863,7 +864,7 @@ func initDataReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/README.md", size: 41, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/data/README.md", size: 41, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -883,7 +884,7 @@ func initDataTemplatesDefault__assetsReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -903,7 +904,7 @@ func initDataTemplatesDefault__assetsImagesIconsAppPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -923,7 +924,7 @@ func initDataTemplatesDefault__assetsImagesLogosLogo_colorSvg() (*asset, error) return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -943,7 +944,7 @@ func initDataTemplatesDefault__assetsImagesLogosWordmarkSvg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -963,7 +964,7 @@ func initDataTemplatesDefault__dataJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__data.json", size: 30, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__data.json", size: 30, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -983,7 +984,7 @@ func initDataTemplatesDefault__documentHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/__document.html", size: 492, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/data/templates/default/__document.html", size: 492, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1003,7 +1004,7 @@ func initDataTemplatesDefaultIndexIndexCss() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/index/index.css", size: 2896, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/data/templates/default/index/index.css", size: 2896, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1023,7 +1024,7 @@ func initDataTemplatesDefaultIndexIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/index/index.html", size: 2361, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/data/templates/default/index/index.html", size: 2361, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1043,7 +1044,7 @@ func initDataTemplatesDefaultIndexIndexJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/data/templates/default/index/index.json", size: 31, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/data/templates/default/index/index.json", size: 31, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1063,7 +1064,7 @@ func initDbReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/db/README.md", size: 84, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/db/README.md", size: 84, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1083,7 +1084,7 @@ func initFlowsMenuFlowYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/flows/menu.flow.yao", size: 813, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/flows/menu.flow.yao", size: 813, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1103,7 +1104,7 @@ func initFormsAccountFormYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/forms/account.form.yao", size: 1194, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/forms/account.form.yao", size: 1194, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1123,7 +1124,7 @@ func initIconsAppIcns() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/icons/app.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/icons/app.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1143,7 +1144,7 @@ func initIconsAppIco() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/icons/app.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/icons/app.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1163,7 +1164,7 @@ func initIconsAppPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1183,7 +1184,7 @@ func initLoginsAdminLoginYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/logins/admin.login.yao", size: 302, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/logins/admin.login.yao", size: 302, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1203,7 +1204,7 @@ func initLogsReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/logs/README.md", size: 28, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/logs/README.md", size: 28, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1223,7 +1224,7 @@ func initModelsAdminUserModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/models/admin/user.mod.yao", size: 6416, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/models/admin/user.mod.yao", size: 6416, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1243,7 +1244,7 @@ func initModelsTestsPetModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/models/tests/pet.mod.yao", size: 525, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/models/tests/pet.mod.yao", size: 525, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1263,7 +1264,7 @@ func initNeoNeoYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/neo/neo.yml", size: 724, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/neo/neo.yml", size: 724, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1283,7 +1284,7 @@ func initPublicReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/README.md", size: 108, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/public/README.md", size: 108, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1303,7 +1304,7 @@ func initPublicAssetsReadmeMd() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/public/assets/README.md", size: 33, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1323,7 +1324,7 @@ func initPublicAssetsImagesIconsAppPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/public/assets/images/icons/app.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1343,7 +1344,7 @@ func initPublicAssetsImagesLogosLogo_colorSvg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/public/assets/images/logos/logo_color.svg", size: 2909, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1363,7 +1364,7 @@ func initPublicAssetsImagesLogosWordmarkSvg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/public/assets/images/logos/wordmark.svg", size: 3615, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1383,7 +1384,7 @@ func initPublicAssetsLibsuiMinJs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/libsui.min.js", size: 12569, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/public/assets/libsui.min.js", size: 12569, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1403,7 +1404,7 @@ func initPublicAssetsLibsuiMinJsMap() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/assets/libsui.min.js.map", size: 38553, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/public/assets/libsui.min.js.map", size: 38553, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1423,7 +1424,7 @@ func initPublicIndexCfg() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/index.cfg", size: 85, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/public/index.cfg", size: 85, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1443,7 +1444,7 @@ func initPublicIndexSui() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/public/index.sui", size: 5682, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/public/index.sui", size: 5682, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1463,7 +1464,7 @@ func initScriptsAccountTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/account.ts", size: 2521, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/scripts/account.ts", size: 2521, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1483,7 +1484,7 @@ func initScriptsAiNeoTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/ai/neo.ts", size: 375, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/scripts/ai/neo.ts", size: 375, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1503,7 +1504,7 @@ func initScriptsTestsTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/tests.ts", size: 1044, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/scripts/tests.ts", size: 1044, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1523,7 +1524,7 @@ func initScriptsUtilsTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/scripts/utils.ts", size: 1230, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/scripts/utils.ts", size: 1230, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1543,7 +1544,7 @@ func initSuisWebSuiYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/suis/web.sui.yao", size: 675, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/suis/web.sui.yao", size: 675, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1563,7 +1564,7 @@ func initTablesAccountTabYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/tables/account.tab.yao", size: 5597, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/tables/account.tab.yao", size: 5597, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1583,7 +1584,7 @@ func initTsconfigJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "init/tsconfig.json", size: 178, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "init/tsconfig.json", size: 178, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1603,7 +1604,7 @@ func libsuiIndexTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/index.ts", size: 13047, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "libsui/index.ts", size: 13047, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1623,7 +1624,7 @@ func libsuiOpenapiTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/openapi.ts", size: 22959, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "libsui/openapi.ts", size: 22959, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1643,7 +1644,7 @@ func libsuiUtilsTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/utils.ts", size: 5959, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "libsui/utils.ts", size: 5959, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1663,7 +1664,7 @@ func libsuiYaoTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "libsui/yao.ts", size: 4338, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "libsui/yao.ts", size: 4338, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1683,7 +1684,7 @@ func publicIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "public/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "public/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1703,7 +1704,7 @@ func uiIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "ui/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "ui/index.html", size: 11, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1723,7 +1724,7 @@ func yaoAssistantsEntityPackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/entity/package.yao", size: 212, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/entity/package.yao", size: 212, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1743,7 +1744,7 @@ func yaoAssistantsEntityPromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/entity/prompts.yml", size: 930, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/entity/prompts.yml", size: 930, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1763,7 +1764,7 @@ func yaoAssistantsKeywordPackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/keyword/package.yao", size: 200, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/keyword/package.yao", size: 200, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1783,7 +1784,7 @@ func yaoAssistantsKeywordPromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/keyword/prompts.yml", size: 990, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/keyword/prompts.yml", size: 990, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1803,7 +1804,7 @@ func yaoAssistantsKeywordSrcIndexTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/keyword/src/index.ts", size: 4104, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/keyword/src/index.ts", size: 4104, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1823,7 +1824,7 @@ func yaoAssistantsNeedsearchPackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/needsearch/package.yao", size: 207, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/needsearch/package.yao", size: 207, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1843,7 +1844,7 @@ func yaoAssistantsNeedsearchPromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/needsearch/prompts.yml", size: 3092, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/needsearch/prompts.yml", size: 3092, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1863,7 +1864,7 @@ func yaoAssistantsNeedsearchSrcIndexTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/needsearch/src/index.ts", size: 2767, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/needsearch/src/index.ts", size: 2767, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1883,7 +1884,7 @@ func yaoAssistantsPromptPackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/prompt/package.yao", size: 186, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/prompt/package.yao", size: 186, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1903,7 +1904,7 @@ func yaoAssistantsPromptPromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/prompt/prompts.yml", size: 621, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/prompt/prompts.yml", size: 621, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1923,7 +1924,7 @@ func yaoAssistantsQuerydslPackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/package.yao", size: 196, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/package.yao", size: 196, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1943,7 +1944,7 @@ func yaoAssistantsQuerydslPromptsAggregationYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/aggregation.yml", size: 6982, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/aggregation.yml", size: 6982, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1963,7 +1964,7 @@ func yaoAssistantsQuerydslPromptsComplexYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/complex.yml", size: 7352, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/complex.yml", size: 7352, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -1983,7 +1984,7 @@ func yaoAssistantsQuerydslPromptsFilterYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/filter.yml", size: 7087, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/filter.yml", size: 7087, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2003,7 +2004,7 @@ func yaoAssistantsQuerydslPromptsJoinYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/join.yml", size: 8167, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/prompts/join.yml", size: 8167, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2023,7 +2024,7 @@ func yaoAssistantsQuerydslPromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/prompts.yml", size: 5836, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/prompts.yml", size: 5836, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2043,7 +2044,7 @@ func yaoAssistantsQuerydslSrcIndexTs() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/querydsl/src/index.ts", size: 1873, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/querydsl/src/index.ts", size: 1873, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2063,7 +2064,7 @@ func yaoAssistantsTitlePackageYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/title/package.yao", size: 178, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/title/package.yao", size: 178, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2083,7 +2084,7 @@ func yaoAssistantsTitlePromptsYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/assistants/title/prompts.yml", size: 958, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/assistants/title/prompts.yml", size: 958, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2103,7 +2104,7 @@ func yaoDataIcons404Png() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/404.png", size: 9342, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/icons/404.png", size: 9342, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2123,7 +2124,7 @@ func yaoDataIconsIconIcns() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/icon.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/icons/icon.icns", size: 67465, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2143,7 +2144,7 @@ func yaoDataIconsIconIco() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/icon.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/icons/icon.ico", size: 54993, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2163,7 +2164,7 @@ func yaoDataIconsIconPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/icons/icon.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/icons/icon.png", size: 34558, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2183,7 +2184,7 @@ func yaoDataIndexHtml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/index.html", size: 282, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/index.html", size: 282, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2203,7 +2204,7 @@ func yaoDataKbProvidersChunkingSemanticEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/chunking/semantic/en.json", size: 5543, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/chunking/semantic/en.json", size: 5543, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2223,7 +2224,7 @@ func yaoDataKbProvidersChunkingSemanticZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/chunking/semantic/zh-cn.json", size: 5446, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/chunking/semantic/zh-cn.json", size: 5446, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2243,7 +2244,7 @@ func yaoDataKbProvidersChunkingStructuredEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/chunking/structured/en.json", size: 2423, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/chunking/structured/en.json", size: 2423, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2263,7 +2264,7 @@ func yaoDataKbProvidersChunkingStructuredZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/chunking/structured/zh-cn.json", size: 2321, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/chunking/structured/zh-cn.json", size: 2321, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2283,7 +2284,7 @@ func yaoDataKbProvidersConverterMcpEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/mcp/en.json", size: 4235, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/mcp/en.json", size: 4235, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2303,7 +2304,7 @@ func yaoDataKbProvidersConverterMcpZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/mcp/zh-cn.json", size: 4060, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/mcp/zh-cn.json", size: 4060, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2323,7 +2324,7 @@ func yaoDataKbProvidersConverterOcrEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/ocr/en.json", size: 6631, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/ocr/en.json", size: 6631, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2343,7 +2344,7 @@ func yaoDataKbProvidersConverterOcrZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/ocr/zh-cn.json", size: 6501, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/ocr/zh-cn.json", size: 6501, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2363,7 +2364,7 @@ func yaoDataKbProvidersConverterOfficeEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/office/en.json", size: 5476, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/office/en.json", size: 5476, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2383,7 +2384,7 @@ func yaoDataKbProvidersConverterOfficeZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/office/zh-cn.json", size: 5356, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/office/zh-cn.json", size: 5356, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2403,7 +2404,7 @@ func yaoDataKbProvidersConverterUtf8EnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/utf8/en.json", size: 292, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/utf8/en.json", size: 292, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2423,7 +2424,7 @@ func yaoDataKbProvidersConverterUtf8ZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/utf8/zh-cn.json", size: 281, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/utf8/zh-cn.json", size: 281, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2443,7 +2444,7 @@ func yaoDataKbProvidersConverterVideoEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/video/en.json", size: 6411, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/video/en.json", size: 6411, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2463,7 +2464,7 @@ func yaoDataKbProvidersConverterVideoZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/video/zh-cn.json", size: 6297, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/video/zh-cn.json", size: 6297, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2483,7 +2484,7 @@ func yaoDataKbProvidersConverterVisionEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/vision/en.json", size: 4085, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/vision/en.json", size: 4085, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2503,7 +2504,7 @@ func yaoDataKbProvidersConverterVisionZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/vision/zh-cn.json", size: 3949, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/vision/zh-cn.json", size: 3949, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2523,7 +2524,7 @@ func yaoDataKbProvidersConverterWhisperEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/whisper/en.json", size: 4449, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/whisper/en.json", size: 4449, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2543,7 +2544,7 @@ func yaoDataKbProvidersConverterWhisperZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/converter/whisper/zh-cn.json", size: 4312, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/converter/whisper/zh-cn.json", size: 4312, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2563,7 +2564,7 @@ func yaoDataKbProvidersEmbeddingFastembedEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/embedding/fastembed/en.json", size: 6865, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/embedding/fastembed/en.json", size: 6865, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2583,7 +2584,7 @@ func yaoDataKbProvidersEmbeddingFastembedZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/embedding/fastembed/zh-cn.json", size: 6685, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/embedding/fastembed/zh-cn.json", size: 6685, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2603,7 +2604,7 @@ func yaoDataKbProvidersEmbeddingOpenaiEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/embedding/openai/en.json", size: 5636, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/embedding/openai/en.json", size: 5636, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2623,7 +2624,7 @@ func yaoDataKbProvidersEmbeddingOpenaiZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/embedding/openai/zh-cn.json", size: 5463, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/embedding/openai/zh-cn.json", size: 5463, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2643,7 +2644,7 @@ func yaoDataKbProvidersExtractionOpenaiEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/extraction/openai/en.json", size: 9110, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/extraction/openai/en.json", size: 9110, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2663,7 +2664,7 @@ func yaoDataKbProvidersExtractionOpenaiZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/extraction/openai/zh-cn.json", size: 8827, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/extraction/openai/zh-cn.json", size: 8827, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2683,7 +2684,7 @@ func yaoDataKbProvidersFetcherHttpEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/http/en.json", size: 5885, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/http/en.json", size: 5885, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2703,7 +2704,7 @@ func yaoDataKbProvidersFetcherHttpZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/http/zh-cn.json", size: 5925, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/http/zh-cn.json", size: 5925, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2723,7 +2724,7 @@ func yaoDataKbProvidersFetcherMcpEnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/mcp/en.json", size: 6819, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/mcp/en.json", size: 6819, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2743,7 +2744,7 @@ func yaoDataKbProvidersFetcherMcpZhCnJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/mcp/zh-cn.json", size: 6611, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/data/kb/providers/fetcher/mcp/zh-cn.json", size: 6611, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2763,7 +2764,7 @@ func yaoFieldsModelTransJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/fields/model.trans.json", size: 14938, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/fields/model.trans.json", size: 14938, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2783,7 +2784,7 @@ func yaoLangsEnUsJson() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/en-US.json", size: 66, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/langs/en-US.json", size: 66, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2803,7 +2804,7 @@ func yaoLangsZhCnGlobalYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-cn/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/langs/zh-cn/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2823,7 +2824,7 @@ func yaoLangsZhCnLoginsAdminLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-cn/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/langs/zh-cn/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2843,7 +2844,7 @@ func yaoLangsZhCnLoginsUserLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-cn/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/langs/zh-cn/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2863,7 +2864,7 @@ func yaoLangsZhHkGlobalYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-hk/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/langs/zh-hk/global.yml", size: 1762, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2883,7 +2884,7 @@ func yaoLangsZhHkLoginsAdminLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-hk/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/langs/zh-hk/logins/admin.login.yml", size: 94, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2903,7 +2904,7 @@ func yaoLangsZhHkLoginsUserLoginYml() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/langs/zh-hk/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/langs/zh-hk/logins/user.login.yml", size: 90, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2923,7 +2924,7 @@ func yaoModelsAgentAssistantModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/assistant.mod.yao", size: 6945, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/agent/assistant.mod.yao", size: 6945, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2943,7 +2944,27 @@ func yaoModelsAgentChatModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/chat.mod.yao", size: 3093, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/agent/chat.mod.yao", size: 3093, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _yaoModelsAgentExecutionModYao = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xb4\x58\x4d\x6f\xdc\x36\x10\xbd\xfb\x57\x0c\x74\x72\x00\x27\x48\xdb\xa4\x80\x7d\x2b\x1a\xa3\x75\x91\x22\x86\xeb\x9e\x0c\x63\xc1\x95\x66\xb5\xb4\xf9\xa1\x0c\x47\xb1\x05\xc3\xff\x3d\x20\xa5\x95\x28\xad\x76\x23\x6b\x9d\x4b\xa2\x1d\x0e\x87\xef\xbd\x21\x87\x1c\x3f\x1d\x01\x24\x46\x68\x4c\xce\x20\x39\x7f\xc4\xb4\x64\x69\x4d\x72\xe2\xcd\x4a\x2c\x51\x79\xfb\x95\x5d\x5a\x86\xc1\x68\x86\x2e\x25\x59\x04\x43\xeb\x83\x1b\x1f\x58\x4b\xc7\x96\x2a\x58\x59\x02\x26\x91\xde\x4b\x93\xc3\xe5\xfb\xb7\x97\x1f\xa1\x58\x0b\x87\x60\x4b\x2e\x4a\x76\x20\x4c\x06\x8e\x05\x97\xae\x8e\xcb\x22\x77\xc9\x19\xdc\x24\x22\x47\xc3\xc9\x09\x24\xe4\x43\xfb\x0f\x57\x39\x46\x9d\xdc\x06\xbf\x65\x29\x15\x4b\xbf\x36\x53\x89\xc1\x44\x28\x32\x6b\x54\x95\x9c\xc1\x4a\x28\x57\x1b\x9d\x25\x4e\xce\xe0\xf4\xf4\xf4\xb4\x89\xbf\x54\x9e\xed\x53\xc7\x3b\xac\xb4\xc0\x8e\x1f\x24\xa9\xd5\xda\x2f\xbf\x87\x59\x1d\x08\x9e\x43\xd4\xd4\xaa\x52\x9b\x00\xfc\x08\x00\xe0\x29\xfc\x1b\x69\x2b\xb3\x40\x2f\xd8\xb8\x2a\x82\xed\xe2\x53\x67\x6b\xc5\x8e\x8d\x11\x8a\x3f\x4a\xb6\x6f\xa5\x49\x09\xbd\x05\x0a\x92\x5a\x50\x05\xf7\x58\x25\xc1\xfb\xf9\x64\x7c\xdd\x16\xf7\x62\x0c\x81\x63\x92\x26\x1f\x41\xd1\x26\x1b\x76\xe0\xf9\xdf\xc8\xaf\x25\x46\xb2\xc8\x0c\x0d\xcb\x95\x44\x8a\xc2\xa1\xc9\x79\x9d\x9c\xc1\xef\x1f\x5a\x9b\x29\x95\x6a\x52\xd0\x26\x29\x0c\x94\x21\x62\x94\xcf\x60\x95\x26\xc3\xc7\xc6\xb8\x97\xa8\x46\xbd\x44\x7a\x19\xcb\x7f\xc3\x9c\x5d\x14\xeb\xc4\xeb\x8d\x0f\x1c\x97\x0e\xa9\xe1\xc9\x15\xac\xc8\x6a\x58\x2c\x2a\x61\xdf\xd5\x3e\x6f\x66\x12\x9f\x4c\x91\x51\xe8\x97\x11\xbc\x46\xa1\x77\xd1\x6b\xc6\x80\xd7\x08\xe1\x88\xc1\x12\x95\x35\xb9\x03\xb6\x3f\x9b\xc9\x9d\x5d\xbe\x8c\xc8\x3f\x76\xb9\x8b\xc7\x67\x69\xee\x31\x83\x3b\xbb\x7c\x57\x7b\x85\x9a\xa3\xad\x91\x6c\x07\xd1\xf6\x73\x99\xb7\xef\x98\x64\x9e\x23\x2d\x02\x85\x2d\x42\x68\x4a\x3d\x96\x97\x7a\x12\x5c\xf7\x26\x45\xa4\xfe\xb6\x0f\xc0\x6b\xe9\xa2\x03\xf6\x20\x1c\x34\x8b\x61\x24\x9d\xdd\x94\xe0\x9b\x24\x55\x36\xbd\xf7\xd5\x6b\x5d\x6a\x11\xca\x18\x7e\xf3\xf1\x6e\x5f\x31\x73\x51\xa9\x9e\x42\xf4\xbf\x81\x7b\x44\xb1\x2b\x31\xc3\x98\x11\xa5\x02\x4d\x16\x72\x08\x09\x95\xc6\x34\x9f\xa9\xd5\x85\x42\xf6\x32\x40\xb2\x12\x52\xd5\x5f\xa9\x30\x29\x2a\xff\xa3\x63\x9c\xe1\x4a\x94\x2a\x2c\xd8\xc6\x7a\x3d\x35\xc2\x55\x36\x59\x8c\xcb\xbe\x77\xa4\xc5\x9f\x25\x91\x2f\xeb\x5d\xb6\x07\x91\x23\x49\xa4\x71\x85\x24\xb1\xb9\xa9\x72\x2b\x94\xf3\x1f\x2c\xdc\xbd\x6b\x84\xf2\xff\x65\xa8\xe4\x37\xa4\xca\x7f\x2b\x14\x14\xc4\x1b\x15\xa6\x17\xf1\xf5\xc4\x49\x6b\x52\xdb\xf2\xdc\xb9\x78\xa1\x56\x9e\x8d\x08\x7e\xcf\x4c\x92\xc9\xe4\x61\xeb\x20\x1c\x7b\xee\x8b\x00\xec\x04\x0a\xb2\x39\xa1\x73\x6f\x46\xb9\xfc\x10\x35\x12\x59\xda\xc6\xcc\xf8\xc8\x63\xf7\x64\xdf\x3b\xde\xde\x7e\x04\x34\x3a\x27\x72\x04\xb9\x8a\x72\xbb\xd9\xb1\x73\xe0\x49\x53\x94\xd3\x25\xbd\xe8\x7b\x47\xf0\xbe\x90\xcc\xa5\x11\x6a\x53\x52\x20\x04\x86\xe3\xa6\x32\x85\x89\x33\x15\x1c\xdd\x4e\x3f\x04\x3a\x32\x27\x82\x7b\xf9\xbe\x79\x30\xc2\x71\xe4\x7a\x85\x85\xa5\xb9\x30\x9b\x83\x33\x11\xe0\x5f\x7d\xef\x18\xda\x2f\x2d\xb4\xe0\x34\x13\x4e\x73\x7c\x27\xc2\xb9\xee\x7b\xc7\x70\x7e\x6d\xe1\xdc\xdc\x7a\xb7\x99\x78\x08\x5d\xa9\x78\x3a\xa2\xab\xa1\x7f\x8c\xe9\xb7\x01\xa6\xda\x79\x26\xb2\xae\xb0\x4d\x84\xf6\x69\x6b\x42\x8c\xed\x43\x8b\x6d\xe3\x77\x10\xba\xb6\xd4\x4e\x45\xf7\x79\x6b\x42\x8c\xee\x63\xa4\xdc\xc6\xf3\xdc\x30\x55\x33\xe1\x39\x16\xc4\x0b\x96\x7a\xe4\xda\xf2\x56\xc7\x42\x17\xe3\x17\x39\x31\x5c\xf7\xe6\xed\xba\xcc\x89\x61\x24\xd4\xc1\x2f\x2d\x34\xd9\x0c\xe0\xe7\x26\x9b\x02\x1b\x4d\x76\x18\xe8\x23\x80\xdb\xa6\x0b\x55\xa1\x38\xf9\x66\xb0\xe6\xd0\xf4\x26\xed\xef\x08\xf9\x5a\xb8\x2f\x26\xc2\xa6\x6d\x56\xc3\x8e\x1b\x8b\x6e\xd8\xb7\x7b\xe3\xbd\xce\xca\x12\xca\xdc\xf4\x47\x5b\x64\x75\x93\x1a\x40\xe3\xde\x26\xf5\x71\x31\x68\x86\x17\x4d\xb4\xe1\x1b\x2d\x6a\x78\x63\x38\xed\x03\xf1\x76\x2b\x49\xb5\x64\x63\x59\xb8\xf0\x23\xf5\xab\xbd\x6e\xb6\xba\xab\xf2\x6b\x89\x24\xd1\xc1\x83\xe4\x75\xf3\x4e\x84\x95\x54\x8c\xb4\xbf\xed\x1d\x63\x12\xda\xa7\x7d\x3c\xda\xfe\xea\x40\x16\x3e\xce\xcf\xe2\xd0\x74\x1b\xe1\x94\xed\x60\xd1\x6b\x48\x7a\x27\x7e\x26\x9d\xe6\x85\xe0\xe7\x80\x30\x42\x55\x4e\xba\x97\x43\xef\x36\xd2\x2e\xe4\xc3\x7d\x74\x18\x6c\xda\xf1\x77\x9b\x65\xd5\x6c\xb3\x64\x70\x70\xdb\x37\xf6\x53\x54\x51\xdc\xe6\xe4\x43\xe2\xec\x8a\x17\x19\xfa\xc6\xc3\xb5\xaf\x62\xdf\x56\x90\x96\xce\xd5\x53\xbd\x2b\x3c\x1f\x3d\x1f\x7d\x0f\x00\x00\xff\xff\x5b\x06\x59\x7f\x59\x13\x00\x00") + +func yaoModelsAgentExecutionModYaoBytes() ([]byte, error) { + return bindataRead( + _yaoModelsAgentExecutionModYao, + "yao/models/agent/execution.mod.yao", + ) +} + +func yaoModelsAgentExecutionModYao() (*asset, error) { + bytes, err := yaoModelsAgentExecutionModYaoBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "yao/models/agent/execution.mod.yao", size: 4953, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2963,7 +2984,7 @@ func yaoModelsAgentMessageModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/message.mod.yao", size: 3712, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/agent/message.mod.yao", size: 3712, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -2983,7 +3004,7 @@ func yaoModelsAgentResumeModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/resume.mod.yao", size: 3896, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/agent/resume.mod.yao", size: 3896, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3003,7 +3024,7 @@ func yaoModelsAgentSearchModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/agent/search.mod.yao", size: 3103, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/agent/search.mod.yao", size: 3103, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3023,7 +3044,7 @@ func yaoModelsAttachmentModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/attachment.mod.yao", size: 4687, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/attachment.mod.yao", size: 4687, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3043,7 +3064,7 @@ func yaoModelsAuditModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/audit.mod.yao", size: 5588, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/audit.mod.yao", size: 5588, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3063,7 +3084,7 @@ func yaoModelsConfigModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/config.mod.yao", size: 1649, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/config.mod.yao", size: 1649, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3083,7 +3104,7 @@ func yaoModelsDslModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/dsl.mod.yao", size: 3826, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/dsl.mod.yao", size: 3826, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3103,7 +3124,7 @@ func yaoModelsInvitationModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/invitation.mod.yao", size: 6693, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/invitation.mod.yao", size: 6693, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3123,7 +3144,7 @@ func yaoModelsJobCategoryModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/job/category.mod.yao", size: 2041, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/job/category.mod.yao", size: 2041, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3143,7 +3164,7 @@ func yaoModelsJobExecutionModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/job/execution.mod.yao", size: 7201, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/job/execution.mod.yao", size: 7201, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3163,7 +3184,7 @@ func yaoModelsJobJobModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/job/job.mod.yao", size: 6429, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/job/job.mod.yao", size: 6429, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3183,7 +3204,7 @@ func yaoModelsJobLogModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/job/log.mod.yao", size: 4711, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/job/log.mod.yao", size: 4711, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3203,7 +3224,7 @@ func yaoModelsKbCollectionModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/kb/collection.mod.yao", size: 5390, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/kb/collection.mod.yao", size: 5390, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3223,7 +3244,7 @@ func yaoModelsKbDocumentModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/kb/document.mod.yao", size: 9906, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/kb/document.mod.yao", size: 9906, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3243,7 +3264,7 @@ func yaoModelsMemberModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/member.mod.yao", size: 14798, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/member.mod.yao", size: 14798, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3263,7 +3284,7 @@ func yaoModelsRoleModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/role.mod.yao", size: 6434, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/role.mod.yao", size: 6434, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3283,7 +3304,7 @@ func yaoModelsTeamModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/team.mod.yao", size: 15823, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/team.mod.yao", size: 15823, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3303,7 +3324,7 @@ func yaoModelsUserOauth_accountModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/user/oauth_account.mod.yao", size: 6928, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/user/oauth_account.mod.yao", size: 6928, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3323,7 +3344,7 @@ func yaoModelsUserTypeModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/user/type.mod.yao", size: 7502, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/user/type.mod.yao", size: 7502, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3343,7 +3364,7 @@ func yaoModelsUserModYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/models/user.mod.yao", size: 12335, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/models/user.mod.yao", size: 12335, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3363,7 +3384,7 @@ func yaoReleaseAppYaz() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/release/app.yaz", size: 181682, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/release/app.yaz", size: 181682, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3383,7 +3404,7 @@ func yaoStoresAgentCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/agent/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3403,7 +3424,7 @@ func yaoStoresAgentMemoryChatXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/memory/chat.xun.yao", size: 497, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/agent/memory/chat.xun.yao", size: 497, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3423,7 +3444,7 @@ func yaoStoresAgentMemoryContextXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/memory/context.xun.yao", size: 507, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/agent/memory/context.xun.yao", size: 507, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3443,7 +3464,7 @@ func yaoStoresAgentMemoryTeamXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/memory/team.xun.yao", size: 483, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/agent/memory/team.xun.yao", size: 483, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3463,7 +3484,7 @@ func yaoStoresAgentMemoryUserXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/agent/memory/user.xun.yao", size: 489, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/agent/memory/user.xun.yao", size: 489, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3483,7 +3504,7 @@ func yaoStoresCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/cache.lru.yao", size: 285, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/cache.lru.yao", size: 285, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3503,7 +3524,7 @@ func yaoStoresKbCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/kb/cache.lru.yao", size: 304, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/kb/cache.lru.yao", size: 304, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3523,7 +3544,7 @@ func yaoStoresKbStoreXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/kb/store.xun.yao", size: 373, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/kb/store.xun.yao", size: 373, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3543,7 +3564,7 @@ func yaoStoresOauthCacheLruYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/oauth/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/oauth/cache.lru.yao", size: 301, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3563,7 +3584,7 @@ func yaoStoresOauthClientXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/oauth/client.xun.yao", size: 377, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/oauth/client.xun.yao", size: 377, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3583,7 +3604,7 @@ func yaoStoresOauthStoreXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/oauth/store.xun.yao", size: 401, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/oauth/store.xun.yao", size: 401, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3603,7 +3624,7 @@ func yaoStoresStoreXunYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/stores/store.xun.yao", size: 369, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/stores/store.xun.yao", size: 369, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3623,7 +3644,7 @@ func yaoUploadersAttachmentLocalYao() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "yao/uploaders/attachment.local.yao", size: 1163, mode: os.FileMode(420), modTime: time.Unix(1768464477, 0)} + info := bindataFileInfo{name: "yao/uploaders/attachment.local.yao", size: 1163, mode: os.FileMode(420), modTime: time.Unix(1768726181, 0)} a := &asset{bytes: bytes, info: info} return a, nil } @@ -3811,6 +3832,7 @@ var _bindata = map[string]func() (*asset, error){ "yao/langs/zh-hk/logins/user.login.yml": yaoLangsZhHkLoginsUserLoginYml, "yao/models/agent/assistant.mod.yao": yaoModelsAgentAssistantModYao, "yao/models/agent/chat.mod.yao": yaoModelsAgentChatModYao, + "yao/models/agent/execution.mod.yao": yaoModelsAgentExecutionModYao, "yao/models/agent/message.mod.yao": yaoModelsAgentMessageModYao, "yao/models/agent/resume.mod.yao": yaoModelsAgentResumeModYao, "yao/models/agent/search.mod.yao": yaoModelsAgentSearchModYao, @@ -4182,6 +4204,7 @@ var _bintree = &bintree{nil, map[string]*bintree{ "agent": {nil, map[string]*bintree{ "assistant.mod.yao": {yaoModelsAgentAssistantModYao, map[string]*bintree{}}, "chat.mod.yao": {yaoModelsAgentChatModYao, map[string]*bintree{}}, + "execution.mod.yao": {yaoModelsAgentExecutionModYao, map[string]*bintree{}}, "message.mod.yao": {yaoModelsAgentMessageModYao, map[string]*bintree{}}, "resume.mod.yao": {yaoModelsAgentResumeModYao, map[string]*bintree{}}, "search.mod.yao": {yaoModelsAgentSearchModYao, map[string]*bintree{}}, diff --git a/messenger/providers/mailer/mailer.go b/messenger/providers/mailer/mailer.go index b9e6a1f9..d12d42ec 100644 --- a/messenger/providers/mailer/mailer.go +++ b/messenger/providers/mailer/mailer.go @@ -3,6 +3,7 @@ package mailer import ( "context" "crypto/tls" + "encoding/base64" "fmt" "net" "net/smtp" @@ -370,6 +371,20 @@ func (p *Provider) buildMessage(message *types.Message) (string, error) { } } + // Check if we have attachments + hasAttachments := len(message.Attachments) > 0 + + if hasAttachments { + // Use multipart/mixed for attachments + return p.buildMessageWithAttachments(&content, message) + } + + // No attachments - use simple format + return p.buildMessageSimple(&content, message) +} + +// buildMessageSimple builds email without attachments +func (p *Provider) buildMessageSimple(content *strings.Builder, message *types.Message) (string, error) { // MIME headers for HTML content if message.HTML != "" { content.WriteString("MIME-Version: 1.0\r\n") @@ -402,6 +417,107 @@ func (p *Provider) buildMessage(message *types.Message) (string, error) { return content.String(), nil } +// buildMessageWithAttachments builds email with attachments using multipart/mixed +func (p *Provider) buildMessageWithAttachments(content *strings.Builder, message *types.Message) (string, error) { + // Use unique boundaries + mixedBoundary := fmt.Sprintf("mixed_%d", time.Now().UnixNano()) + altBoundary := fmt.Sprintf("alt_%d", time.Now().UnixNano()) + + content.WriteString("MIME-Version: 1.0\r\n") + content.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n", mixedBoundary)) + content.WriteString("\r\n") + + // Start mixed boundary + content.WriteString(fmt.Sprintf("--%s\r\n", mixedBoundary)) + + // Add body content + if message.HTML != "" && message.Body != "" { + // Both text and HTML - use multipart/alternative + content.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", altBoundary)) + content.WriteString("\r\n") + + // Plain text part + content.WriteString(fmt.Sprintf("--%s\r\n", altBoundary)) + content.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + content.WriteString("\r\n") + content.WriteString(message.Body) + content.WriteString("\r\n") + + // HTML part + content.WriteString(fmt.Sprintf("--%s\r\n", altBoundary)) + content.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + content.WriteString("\r\n") + content.WriteString(message.HTML) + content.WriteString("\r\n") + + // End alternative boundary + content.WriteString(fmt.Sprintf("--%s--\r\n", altBoundary)) + } else if message.HTML != "" { + // HTML only + content.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + content.WriteString("\r\n") + content.WriteString(message.HTML) + content.WriteString("\r\n") + } else { + // Plain text only + content.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + content.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + content.WriteString("\r\n") + content.WriteString(message.Body) + content.WriteString("\r\n") + } + + // Add attachments + for _, attachment := range message.Attachments { + content.WriteString(fmt.Sprintf("--%s\r\n", mixedBoundary)) + + // Determine content type + contentType := attachment.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + + // Determine disposition + disposition := "attachment" + if attachment.Inline { + disposition = "inline" + } + + // Write attachment headers + content.WriteString(fmt.Sprintf("Content-Type: %s; name=\"%s\"\r\n", contentType, attachment.Filename)) + content.WriteString("Content-Transfer-Encoding: base64\r\n") + content.WriteString(fmt.Sprintf("Content-Disposition: %s; filename=\"%s\"\r\n", disposition, attachment.Filename)) + + // Add Content-ID for inline attachments + if attachment.Inline && attachment.CID != "" { + content.WriteString(fmt.Sprintf("Content-ID: <%s>\r\n", attachment.CID)) + } + + content.WriteString("\r\n") + + // Encode attachment content as base64 + encoded := base64.StdEncoding.EncodeToString(attachment.Content) + + // Split into 76-character lines (RFC 2045) + for i := 0; i < len(encoded); i += 76 { + end := i + 76 + if end > len(encoded) { + end = len(encoded) + } + content.WriteString(encoded[i:end]) + content.WriteString("\r\n") + } + } + + // End mixed boundary + content.WriteString(fmt.Sprintf("--%s--\r\n", mixedBoundary)) + + return content.String(), nil +} + // extractEmailAddress extracts the email address from a string that may contain display name // e.g., "John Doe " -> "john@example.com" func extractEmailAddress(address string) string { diff --git a/messenger/providers/mailer/mailer_test.go b/messenger/providers/mailer/mailer_test.go index 5e2f611c..04d8427f 100644 --- a/messenger/providers/mailer/mailer_test.go +++ b/messenger/providers/mailer/mailer_test.go @@ -768,3 +768,156 @@ func TestProvider_TriggerWebhook(t *testing.T) { assert.Nil(t, msg) assert.Contains(t, err.Error(), "TriggerWebhook not supported for SMTP/mailer provider") } + +// ============================================================================ +// Attachment Tests +// ============================================================================ + +func TestBuildMessage_WithAttachments(t *testing.T) { + config := types.ProviderConfig{ + Name: "test", + Connector: "mailer", + Options: map[string]interface{}{ + "smtp": map[string]interface{}{ + "host": "smtp.example.com", + "port": 587, + "username": "test@example.com", + "password": "testpass", + "from": "sender@example.com", + }, + }, + } + + provider, err := NewMailerProvider(config) + require.NoError(t, err) + + t.Run("single_attachment", func(t *testing.T) { + message := &types.Message{ + Type: types.MessageTypeEmail, + To: []string{"test@example.com"}, + Subject: "Test with Attachment", + Body: "This is a test email with attachment", + Attachments: []types.Attachment{ + { + Filename: "test.txt", + ContentType: "text/plain", + Content: []byte("Hello, this is test content!"), + }, + }, + } + + content, err := provider.buildMessage(message) + require.NoError(t, err) + + // Verify multipart/mixed boundary + assert.Contains(t, content, "multipart/mixed") + assert.Contains(t, content, "Content-Disposition: attachment") + assert.Contains(t, content, `filename="test.txt"`) + assert.Contains(t, content, "Content-Transfer-Encoding: base64") + }) + + t.Run("multiple_attachments", func(t *testing.T) { + message := &types.Message{ + Type: types.MessageTypeEmail, + To: []string{"test@example.com"}, + Subject: "Test with Multiple Attachments", + Body: "This is a test email with multiple attachments", + HTML: "

This is a test email with multiple attachments

", + Attachments: []types.Attachment{ + { + Filename: "doc1.txt", + ContentType: "text/plain", + Content: []byte("Document 1 content"), + }, + { + Filename: "doc2.pdf", + ContentType: "application/pdf", + Content: []byte("%PDF-1.4 fake pdf"), + }, + }, + } + + content, err := provider.buildMessage(message) + require.NoError(t, err) + + // Verify both attachments are present + assert.Contains(t, content, `filename="doc1.txt"`) + assert.Contains(t, content, `filename="doc2.pdf"`) + assert.Contains(t, content, "text/plain") + assert.Contains(t, content, "application/pdf") + }) + + t.Run("inline_attachment", func(t *testing.T) { + message := &types.Message{ + Type: types.MessageTypeEmail, + To: []string{"test@example.com"}, + Subject: "Test with Inline Image", + HTML: `

Image:

`, + Attachments: []types.Attachment{ + { + Filename: "logo.png", + ContentType: "image/png", + Content: []byte{0x89, 0x50, 0x4E, 0x47}, // PNG magic bytes + Inline: true, + CID: "logo123", + }, + }, + } + + content, err := provider.buildMessage(message) + require.NoError(t, err) + + // Verify inline disposition and Content-ID + assert.Contains(t, content, "Content-Disposition: inline") + assert.Contains(t, content, "Content-ID: ") + }) + + t.Run("no_attachments", func(t *testing.T) { + message := &types.Message{ + Type: types.MessageTypeEmail, + To: []string{"test@example.com"}, + Subject: "Test without Attachment", + Body: "This is a plain text email", + } + + content, err := provider.buildMessage(message) + require.NoError(t, err) + + // Should not contain multipart/mixed + assert.NotContains(t, content, "multipart/mixed") + assert.Contains(t, content, "text/plain") + }) +} + +func TestSend_EmailWithAttachments_RealAPI(t *testing.T) { + if testing.Short() { + t.Skip("Skipping real API test in short mode") + } + + config := loadPrimaryTestConfig(t) + provider, err := NewMailerProvider(config) + require.NoError(t, err) + + ctx := context.Background() + emailMessage := &types.Message{ + Type: types.MessageTypeEmail, + To: []string{TestEmailAgent}, + Subject: "SMTP Test Email with Attachment - " + time.Now().Format("2006-01-02 15:04:05"), + Body: "This is a test email with attachment sent via SMTP", + HTML: "

SMTP Test

This email has an attachment.

", + Attachments: []types.Attachment{ + { + Filename: "test-attachment.txt", + ContentType: "text/plain", + Content: []byte("This is a test attachment content.\nLine 2 of the attachment.\nLine 3."), + }, + }, + } + + err = provider.Send(ctx, emailMessage) + if err != nil { + t.Logf("Real SMTP call with attachment failed (may be expected in CI): %v", err) + } else { + t.Log("Real SMTP call with attachment succeeded") + } +} diff --git a/messenger/providers/mailgun/mailgun.go b/messenger/providers/mailgun/mailgun.go index 82d22094..7a916f05 100644 --- a/messenger/providers/mailgun/mailgun.go +++ b/messenger/providers/mailgun/mailgun.go @@ -1,10 +1,13 @@ package mailgun import ( + "bytes" "context" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "net/url" "strings" "time" @@ -218,6 +221,17 @@ func (p *Provider) Close() error { func (p *Provider) sendEmail(ctx context.Context, message *types.Message) error { apiURL := fmt.Sprintf("%s/%s/messages", p.baseURL, p.domain) + // Check if we have attachments - use multipart/form-data if so + if len(message.Attachments) > 0 { + return p.sendEmailWithAttachments(ctx, apiURL, message) + } + + // No attachments - use simple URL-encoded form + return p.sendEmailSimple(ctx, apiURL, message) +} + +// sendEmailSimple sends email without attachments using URL-encoded form +func (p *Provider) sendEmailSimple(ctx context.Context, apiURL string, message *types.Message) error { // Prepare form data data := url.Values{} @@ -295,3 +309,134 @@ func (p *Provider) sendEmail(ctx context.Context, message *types.Message) error return nil } + +// sendEmailWithAttachments sends email with attachments using multipart/form-data +func (p *Provider) sendEmailWithAttachments(ctx context.Context, apiURL string, message *types.Message) error { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + // From address + from := message.From + if from == "" { + from = p.from + } + if err := writer.WriteField("from", from); err != nil { + return fmt.Errorf("failed to write from field: %w", err) + } + + // To addresses + for _, to := range message.To { + if err := writer.WriteField("to", to); err != nil { + return fmt.Errorf("failed to write to field: %w", err) + } + } + + // Subject + if err := writer.WriteField("subject", message.Subject); err != nil { + return fmt.Errorf("failed to write subject field: %w", err) + } + + // Text body + if message.Body != "" { + if err := writer.WriteField("text", message.Body); err != nil { + return fmt.Errorf("failed to write text field: %w", err) + } + } + + // HTML body + if message.HTML != "" { + if err := writer.WriteField("html", message.HTML); err != nil { + return fmt.Errorf("failed to write html field: %w", err) + } + } + + // Custom headers + if message.Headers != nil { + for key, value := range message.Headers { + if err := writer.WriteField("h:"+key, value); err != nil { + return fmt.Errorf("failed to write header field: %w", err) + } + } + } + + // Custom variables (metadata) + if message.Metadata != nil { + for key, value := range message.Metadata { + if str, ok := value.(string); ok { + if err := writer.WriteField("v:"+key, str); err != nil { + return fmt.Errorf("failed to write metadata field: %w", err) + } + } + } + } + + // Priority + if message.Priority > 0 { + if err := writer.WriteField("o:priority", fmt.Sprintf("%d", message.Priority)); err != nil { + return fmt.Errorf("failed to write priority field: %w", err) + } + } + + // Scheduled sending + if message.ScheduledAt != nil { + if err := writer.WriteField("o:deliverytime", message.ScheduledAt.Format(time.RFC1123Z)); err != nil { + return fmt.Errorf("failed to write deliverytime field: %w", err) + } + } + + // Add attachments + for _, attachment := range message.Attachments { + fieldName := "attachment" + if attachment.Inline { + fieldName = "inline" + } + + // Create form file with proper headers + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, attachment.Filename)) + if attachment.ContentType != "" { + h.Set("Content-Type", attachment.ContentType) + } else { + h.Set("Content-Type", "application/octet-stream") + } + + part, err := writer.CreatePart(h) + if err != nil { + return fmt.Errorf("failed to create attachment part: %w", err) + } + + if _, err := part.Write(attachment.Content); err != nil { + return fmt.Errorf("failed to write attachment content: %w", err) + } + } + + // Close multipart writer + if err := writer.Close(); err != nil { + return fmt.Errorf("failed to close multipart writer: %w", err) + } + + // Create request with context + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, &body) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Set authentication and content type + req.SetBasicAuth("api", p.apiKey) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + // Send request + resp, err := p.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Mailgun API error: %s - %s", resp.Status, string(respBody)) + } + + return nil +} diff --git a/messenger/providers/mailgun/mailgun_receive.go b/messenger/providers/mailgun/mailgun_receive.go index a37b00a7..199fd55b 100644 --- a/messenger/providers/mailgun/mailgun_receive.go +++ b/messenger/providers/mailgun/mailgun_receive.go @@ -28,7 +28,7 @@ func (p *Provider) TriggerWebhook(c interface{}) (*types.Message, error) { // Extract common Mailgun webhook fields event := ginCtx.Request.FormValue("event") recipient := ginCtx.Request.FormValue("recipient") - messageId := ginCtx.Request.FormValue("message-id") + messageID := ginCtx.Request.FormValue("message-id") timestamp := ginCtx.Request.FormValue("timestamp") token := ginCtx.Request.FormValue("token") signature := ginCtx.Request.FormValue("signature") @@ -38,8 +38,8 @@ func (p *Provider) TriggerWebhook(c interface{}) (*types.Message, error) { if recipient != "" { message.To = []string{recipient} } - if messageId != "" { - message.Metadata["message_id"] = messageId + if messageID != "" { + message.Metadata["message_id"] = messageID } // Store webhook-specific data diff --git a/messenger/providers/mailgun/mailgun_test.go b/messenger/providers/mailgun/mailgun_test.go index 2f34982c..c56b8b49 100644 --- a/messenger/providers/mailgun/mailgun_test.go +++ b/messenger/providers/mailgun/mailgun_test.go @@ -551,3 +551,130 @@ func BenchmarkValidate(b *testing.B) { } } } + +// ============================================================================ +// Attachment Tests +// ============================================================================ + +func TestSend_EmailWithAttachments_MockServer(t *testing.T) { + // Create a mock HTTP server that validates the multipart request + var receivedContentType string + var receivedBody []byte + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedContentType = r.Header.Get("Content-Type") + + // Read the body + body, _ := r.Body.Read(make([]byte, 1024*1024)) + _ = body + receivedBody = make([]byte, r.ContentLength) + r.Body.Read(receivedBody) + + // Return success + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id": "test-id", "message": "Queued"}`)) + })) + defer server.Close() + + config := loadTestConfig(t) + provider, err := NewMailgunProvider(config) + require.NoError(t, err) + + // Override base URL to use mock server + provider.baseURL = server.URL + + ctx := context.Background() + emailMessage := &types.Message{ + Type: types.MessageTypeEmail, + To: []string{"test@example.com"}, + Subject: "Test Email with Attachment", + Body: "This is a test email with attachment", + HTML: "

Test

This is a test email with attachment

", + Attachments: []types.Attachment{ + { + Filename: "test.txt", + ContentType: "text/plain", + Content: []byte("Hello, this is a test attachment content!"), + }, + { + Filename: "test.pdf", + ContentType: "application/pdf", + Content: []byte("%PDF-1.4 fake pdf content"), + }, + }, + } + + err = provider.Send(ctx, emailMessage) + assert.NoError(t, err) + + // Verify the request used multipart/form-data + assert.Contains(t, receivedContentType, "multipart/form-data") +} + +func TestSend_EmailWithInlineAttachment_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id": "test-id", "message": "Queued"}`)) + })) + defer server.Close() + + config := loadTestConfig(t) + provider, err := NewMailgunProvider(config) + require.NoError(t, err) + + provider.baseURL = server.URL + + ctx := context.Background() + emailMessage := &types.Message{ + Type: types.MessageTypeEmail, + To: []string{"test@example.com"}, + Subject: "Test Email with Inline Image", + Body: "This is a test email with inline image", + HTML: `

Test

Image:

`, + Attachments: []types.Attachment{ + { + Filename: "logo.png", + ContentType: "image/png", + Content: []byte{0x89, 0x50, 0x4E, 0x47}, // PNG magic bytes + Inline: true, + CID: "logo123", + }, + }, + } + + err = provider.Send(ctx, emailMessage) + assert.NoError(t, err) +} + +func TestSend_EmailWithAttachments_RealAPI(t *testing.T) { + if testing.Short() { + t.Skip("Skipping real API test in short mode") + } + + config := loadTestConfig(t) + provider, err := NewMailgunProvider(config) + require.NoError(t, err) + + ctx := context.Background() + emailMessage := &types.Message{ + Type: types.MessageTypeEmail, + To: []string{TestEmailAgent}, + Subject: "Unit Test Email with Attachment - " + time.Now().Format("2006-01-02 15:04:05"), + Body: "This is a unit test email with attachment sent via real Mailgun API", + HTML: "

Unit Test

This email has an attachment.

", + Attachments: []types.Attachment{ + { + Filename: "test-attachment.txt", + ContentType: "text/plain", + Content: []byte("This is a test attachment content.\nLine 2 of the attachment."), + }, + }, + } + + err = provider.Send(ctx, emailMessage) + if err != nil { + t.Logf("Real API call with attachment failed (may be expected in CI): %v", err) + } else { + t.Log("Real Mailgun API call with attachment succeeded") + } +} diff --git a/model/model.go b/model/model.go index 45f88fa1..3ae6d65b 100644 --- a/model/model.go +++ b/model/model.go @@ -22,6 +22,7 @@ import ( var systemModels = map[string]string{ "__yao.agent.assistant": "yao/models/agent/assistant.mod.yao", "__yao.agent.chat": "yao/models/agent/chat.mod.yao", + "__yao.agent.execution": "yao/models/agent/execution.mod.yao", "__yao.agent.message": "yao/models/agent/message.mod.yao", "__yao.agent.resume": "yao/models/agent/resume.mod.yao", "__yao.agent.search": "yao/models/agent/search.mod.yao", diff --git a/test/utils.go b/test/utils.go index b7d06d5b..11bf75f5 100644 --- a/test/utils.go +++ b/test/utils.go @@ -197,6 +197,7 @@ var testServer *http.Server = nil var testSystemModels = map[string]string{ "__yao.agent.assistant": "yao/models/agent/assistant.mod.yao", "__yao.agent.chat": "yao/models/agent/chat.mod.yao", + "__yao.agent.execution": "yao/models/agent/execution.mod.yao", "__yao.agent.message": "yao/models/agent/message.mod.yao", "__yao.agent.resume": "yao/models/agent/resume.mod.yao", "__yao.agent.search": "yao/models/agent/search.mod.yao", diff --git a/yao/models/agent/execution.mod.yao b/yao/models/agent/execution.mod.yao new file mode 100644 index 00000000..288b345d --- /dev/null +++ b/yao/models/agent/execution.mod.yao @@ -0,0 +1,198 @@ +{ + "name": "Execution", + "label": "Robot Execution", + "description": "Robot execution history for tracking P0-P5 phase outputs and status", + "tags": ["agent", "robot", "system"], + "builtin": true, + "readonly": false, + "sort": 9999, + "table": { "name": "agent_execution", "comment": "Robot execution history table" }, + "columns": [ + { + "name": "id", + "type": "ID", + "label": "ID", + "comment": "Auto-increment primary key" + }, + { + "name": "execution_id", + "type": "string", + "label": "Execution ID", + "comment": "Unique execution identifier", + "length": 64, + "nullable": false, + "unique": true, + "index": true + }, + { + "name": "member_id", + "type": "string", + "label": "Member ID", + "comment": "Robot member ID (user identity from __yao.member)", + "length": 64, + "nullable": false, + "index": true + }, + { + "name": "team_id", + "type": "string", + "label": "Team ID", + "comment": "Team ID the robot belongs to", + "length": 64, + "nullable": false, + "index": true + }, + { + "name": "job_id", + "type": "string", + "label": "Job ID", + "comment": "Linked job.Job ID for monitoring", + "length": 64, + "nullable": true, + "index": true + }, + { + "name": "trigger_type", + "type": "enum", + "label": "Trigger Type", + "comment": "How this execution was triggered", + "option": ["clock", "human", "event"], + "nullable": false, + "index": true + }, + { + "name": "status", + "type": "enum", + "label": "Status", + "comment": "Execution status", + "option": ["pending", "running", "completed", "failed", "cancelled"], + "default": "pending", + "nullable": false, + "index": true + }, + { + "name": "phase", + "type": "enum", + "label": "Phase", + "comment": "Current execution phase", + "option": ["inspiration", "goals", "tasks", "run", "delivery", "learning"], + "default": "inspiration", + "nullable": false, + "index": true + }, + { + "name": "current", + "type": "json", + "label": "Current State", + "comment": "Current executing state (task_index, progress)", + "nullable": true + }, + { + "name": "error", + "type": "text", + "label": "Error", + "comment": "Error message if execution failed", + "nullable": true + }, + { + "name": "input", + "type": "json", + "label": "Input", + "comment": "Original trigger input (TriggerInput)", + "nullable": true + }, + { + "name": "inspiration", + "type": "json", + "label": "Inspiration", + "comment": "P0 output (InspirationReport)", + "nullable": true + }, + { + "name": "goals", + "type": "json", + "label": "Goals", + "comment": "P1 output (Goals)", + "nullable": true + }, + { + "name": "tasks", + "type": "json", + "label": "Tasks", + "comment": "P2 output ([]Task)", + "nullable": true + }, + { + "name": "results", + "type": "json", + "label": "Results", + "comment": "P3 output ([]TaskResult)", + "nullable": true + }, + { + "name": "delivery", + "type": "json", + "label": "Delivery", + "comment": "P4 output (DeliveryResult)", + "nullable": true + }, + { + "name": "learning", + "type": "json", + "label": "Learning", + "comment": "P5 output ([]LearningEntry)", + "nullable": true + }, + { + "name": "start_time", + "type": "timestamp", + "label": "Start Time", + "comment": "Execution start timestamp", + "nullable": true, + "index": true + }, + { + "name": "end_time", + "type": "timestamp", + "label": "End Time", + "comment": "Execution end timestamp", + "nullable": true, + "index": true + } + ], + "relations": { + "member": { + "type": "hasOne", + "model": "__yao.member", + "key": "member_id", + "foreign": "member_id" + } + }, + "indexes": [ + { + "name": "idx_agent_execution_member_status", + "columns": ["member_id", "status"], + "type": "index", + "comment": "Index for member execution queries with status filter" + }, + { + "name": "idx_agent_execution_team_status", + "columns": ["team_id", "status"], + "type": "index", + "comment": "Index for team execution queries with status filter" + }, + { + "name": "idx_agent_execution_trigger_start", + "columns": ["trigger_type", "start_time"], + "type": "index", + "comment": "Index for trigger type analysis" + }, + { + "name": "idx_agent_execution_member_start", + "columns": ["member_id", "start_time"], + "type": "index", + "comment": "Index for robot execution history by member" + } + ], + "option": { "timestamps": true, "soft_deletes": false, "permission": true } +}