Merge pull request #1434 from trheyi/main

Enhance Robot Delivery Logic and Context Management
This commit is contained in:
Max 2026-01-28 15:39:07 +08:00 committed by GitHub
commit 25c532d17e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 200 additions and 30 deletions

View file

@ -4,7 +4,6 @@ import (
"context"
"fmt"
"sync"
"time"
"github.com/yaoapp/yao/agent/memory"
"github.com/yaoapp/yao/agent/output/message"
@ -411,12 +410,12 @@ func SendInterrupt(contextID string, signal *InterruptSignal) error {
// generateContextID generates a unique context ID
func generateContextID() string {
return fmt.Sprintf("ctx-%d", time.Now().UnixNano())
return message.GenerateNanoID()
}
// RequestID returns the request ID for the context
// RequestID returns a unique request ID using NanoID
func (ctx *Context) RequestID() string {
return fmt.Sprintf("%s", ctx.ID)
return message.GenerateNanoID()
}
// TraceID returns the trace ID for the context

View file

@ -167,6 +167,7 @@ func loadRobotFromDB(memberID string) (*types.Robot, error) {
"id", "member_id", "team_id", "display_name", "bio",
"system_prompt", "robot_status", "autonomous_mode",
"robot_config", "robot_email", "agents", "mcp_servers",
"manager_id",
},
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},

View file

@ -26,6 +26,7 @@ var memberFields = []interface{}{
"robot_email",
"agents",
"mcp_servers",
"manager_id",
}
// SetMemberModel sets the member model name

View file

@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/yaoapp/gou/model"
robottypes "github.com/yaoapp/yao/agent/robot/types"
)
@ -94,20 +95,18 @@ func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Executi
// routeToDeliveryCenter sends content to the Delivery Center for actual delivery
// The Delivery Center decides which channels to use based on robot/user preferences
//
// Delivery logic:
// 1. Manager email: ALWAYS send to manager if manager_id is set (mandatory)
// 2. Additional targets: Append configured email/webhook/process targets
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)
// Build final delivery preferences by merging manager email + configured targets
prefs := buildDeliveryPreferences(robot)
if prefs == nil || !hasActiveChannels(prefs) {
// No channels configured - mark as success but with no results
exec.Delivery.Success = true
return nil
}
@ -283,6 +282,98 @@ func hasActiveChannels(prefs *robottypes.DeliveryPreferences) bool {
return false
}
// buildDeliveryPreferences builds the final delivery preferences by:
// 1. Always including manager email if manager_id is set (mandatory)
// 2. Appending all configured email/webhook/process targets
func buildDeliveryPreferences(robot *robottypes.Robot) *robottypes.DeliveryPreferences {
if robot == nil {
return nil
}
prefs := &robottypes.DeliveryPreferences{}
// Step 1: Get manager email (mandatory if manager_id is set)
managerEmail := robot.ManagerEmail
if managerEmail == "" && robot.ManagerID != "" {
managerEmail = getManagerEmail(robot.ManagerID)
if managerEmail != "" {
robot.ManagerEmail = managerEmail // Cache for future use
}
}
// Step 2: Build email targets (manager first, then configured targets)
var emailTargets []robottypes.EmailTarget
// Add manager email as first target (mandatory)
if managerEmail != "" {
emailTargets = append(emailTargets, robottypes.EmailTarget{
To: []string{managerEmail},
})
}
// Append configured email targets
if robot.Config != nil && robot.Config.Delivery != nil && robot.Config.Delivery.Email != nil {
for _, target := range robot.Config.Delivery.Email.Targets {
if len(target.To) > 0 {
emailTargets = append(emailTargets, target)
}
}
}
// Set email preference if we have any targets
if len(emailTargets) > 0 {
prefs.Email = &robottypes.EmailPreference{
Enabled: true,
Targets: emailTargets,
}
}
// Step 3: Copy webhook preferences from config (if enabled)
if robot.Config != nil && robot.Config.Delivery != nil && robot.Config.Delivery.Webhook != nil {
if robot.Config.Delivery.Webhook.Enabled && len(robot.Config.Delivery.Webhook.Targets) > 0 {
prefs.Webhook = robot.Config.Delivery.Webhook
}
}
// Step 4: Copy process preferences from config (if enabled)
if robot.Config != nil && robot.Config.Delivery != nil && robot.Config.Delivery.Process != nil {
if robot.Config.Delivery.Process.Enabled && len(robot.Config.Delivery.Process.Targets) > 0 {
prefs.Process = robot.Config.Delivery.Process
}
}
return prefs
}
// getManagerEmail retrieves the manager's email from __yao.member table by member_id
// manager_id in Robot refers to a member_id in __yao.member table
func getManagerEmail(managerID string) string {
if managerID == "" {
return ""
}
m := model.Select("__yao.member")
if m == nil {
return ""
}
records, err := m.Get(model.QueryParam{
Select: []interface{}{"email"},
Wheres: []model.QueryWhere{
{Column: "member_id", Value: managerID},
},
Limit: 1,
})
if err != nil || len(records) == 0 {
return ""
}
if email, ok := records[0]["email"].(string); ok {
return email
}
return ""
}
// FormatDeliveryInput formats the full execution context for the Delivery Agent
func (f *InputFormatter) FormatDeliveryInput(exec *robottypes.Execution, robot *robottypes.Robot) string {
if exec == nil {

View file

@ -3,6 +3,9 @@ package standard
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@ -11,6 +14,7 @@ import (
"time"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/gou/text"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/attachment"
"github.com/yaoapp/yao/messenger"
@ -116,11 +120,13 @@ func (dc *DeliveryCenter) sendEmail(
return result
}
// Build email message
// Build email message with HTML content
htmlBody, plainBody := buildEmailBody(target.Template, content)
msg := &messengerTypes.Message{
To: target.To,
Subject: buildEmailSubject(target.Subject, target.Template, content, deliveryCtx),
Body: buildEmailBody(target.Template, content),
Body: plainBody, // Plain text fallback
HTML: htmlBody, // HTML content for rich email display
Type: messengerTypes.MessageTypeEmail,
}
@ -217,10 +223,11 @@ func (dc *DeliveryCenter) postWebhook(
req.Header.Set(key, value)
}
// Add secret if configured (for signature verification)
// Add HMAC signature if secret is configured
if target.Secret != "" {
// TODO: Implement HMAC signature
req.Header.Set("X-Webhook-Secret", target.Secret)
signature := computeHMACSignature(payloadBytes, target.Secret)
req.Header.Set("X-Yao-Signature", signature)
req.Header.Set("X-Yao-Signature-Algorithm", "HMAC-SHA256")
}
// Send request
@ -295,11 +302,30 @@ func (dc *DeliveryCenter) callProcess(
}
result.Success = true
result.Details = proc.Value
// Convert proc.Value to JSON-serializable format to avoid func type issues
result.Details = toJSONSerializable(proc.Value)
return result
}
// toJSONSerializable ensures the value can be JSON serialized
// Returns the original value if serializable, or a string fallback if not
func toJSONSerializable(v interface{}) interface{} {
if v == nil {
return nil
}
// Try to marshal to check if it's JSON serializable
_, err := json.Marshal(v)
if err != nil {
// If it can't be serialized (e.g., contains func), return a string representation
return fmt.Sprintf("%v", v)
}
// Return original value if it's serializable
return v
}
// buildEmailSubject builds the email subject line
func buildEmailSubject(subject, template string, content *robottypes.DeliveryContent, ctx *robottypes.DeliveryContext) string {
// Use explicit subject if provided
@ -322,13 +348,24 @@ func buildEmailSubject(subject, template string, content *robottypes.DeliveryCon
}
// buildEmailBody builds the email body content
func buildEmailBody(template string, content *robottypes.DeliveryContent) string {
// buildEmailBody returns HTML and plain text versions of the email body
// Returns: (htmlBody, plainBody)
func buildEmailBody(template string, content *robottypes.DeliveryContent) (string, string) {
// TODO: Implement template rendering
// For now, just use the body directly
if content.Body != "" {
return content.Body
// Get markdown content (used as plain text fallback)
markdown := content.Body
if markdown == "" {
markdown = content.Summary
}
return content.Summary
// Convert Markdown to HTML for rich email display
html, err := text.MarkdownToHTML(markdown)
if err != nil {
// Fallback: use markdown as both HTML and plain text
return markdown, markdown
}
return html, markdown
}
// convertAttachments converts DeliveryAttachment to messenger Attachment format
@ -376,3 +413,26 @@ func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAt
return result
}
// ============================================================================
// Webhook Signature
// ============================================================================
// computeHMACSignature computes HMAC-SHA256 signature for webhook payload
// Returns hex-encoded signature string
func computeHMACSignature(payload []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
// VerifyHMACSignature verifies the HMAC-SHA256 signature of a webhook payload
// Headers:
// - X-Yao-Signature: hex-encoded HMAC-SHA256 signature
// - X-Yao-Signature-Algorithm: "HMAC-SHA256"
//
// Returns true if the signature is valid
func VerifyHMACSignature(payload []byte, secret, signature string) bool {
expected := computeHMACSignature(payload, secret)
return hmac.Equal([]byte(expected), []byte(signature))
}

View file

@ -8,8 +8,8 @@ import "sync"
var (
// defaultEmailChannel - default messenger channel name for sending emails
// Can be configured via SetDefaultEmailChannel()
// Default: "email" (maps to messengers/channels.yao configuration)
defaultEmailChannel = "email"
// Default: "default" (maps to messengers/channels.yao configuration)
defaultEmailChannel = "default"
// configMu protects global configuration
configMu sync.RWMutex

View file

@ -23,6 +23,10 @@ type Robot struct {
AutonomousMode bool `json:"autonomous_mode"`
RobotEmail string `json:"robot_email"` // Robot's email address for sending emails
// Manager info (from __yao.member)
ManagerID string `json:"manager_id"` // Direct manager user_id (who manages this robot)
ManagerEmail string `json:"manager_email"` // Manager's email address (for default delivery)
// Parsed config (from robot_config JSON field)
Config *Config `json:"-"`
@ -393,6 +397,8 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
SystemPrompt: getString(m, "system_prompt"),
AutonomousMode: getBool(m, "autonomous_mode"),
RobotEmail: getString(m, "robot_email"),
ManagerID: getString(m, "manager_id"),
ManagerEmail: getString(m, "manager_email"),
}
// Parse robot_status

5
go.mod
View file

@ -47,6 +47,8 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/JohannesKaufmann/dom v0.2.0 // indirect
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 // indirect
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
@ -124,7 +126,7 @@ require (
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
@ -147,6 +149,7 @@ require (
github.com/xuri/nfp v0.0.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
golang.org/x/arch v0.17.0 // indirect
golang.org/x/image v0.29.0 // indirect

12
go.sum
View file

@ -1,5 +1,9 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ=
github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 h1:mklaPbT4f/EiDr1Q+zPrEt9lgKAkVrIBtWf33d9GpVA=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0/go.mod h1:D56Cl9r8M5i3UwAchE+LlLc5hPN3kJtdZNVJn06lSHU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
@ -272,8 +276,10 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
@ -344,6 +350,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=

View file

@ -93,9 +93,10 @@ func (m *manager) addUpdateAndBroadcast(update *types.TraceUpdate) {
// Persist to driver (synchronous - no race)
if err := m.driver.SaveUpdate(context.Background(), m.traceID, update); err != nil {
log.Trace("[MANAGER] addUpdateAndBroadcast: failed to save update type=%s for trace %s: %v", update.Type, m.traceID, err)
} else {
log.Trace("[MANAGER] addUpdateAndBroadcast: successfully saved update type=%s for trace %s", update.Type, m.traceID)
}
// else {
// log.Trace("[MANAGER] addUpdateAndBroadcast: successfully saved update type=%s for trace %s", update.Type, m.traceID)
// }
// Add to in-memory history
m.stateAddUpdate(update)