Files
Acbox Liu b3a39ad93d refactor: replace persistent subagents with ephemeral spawn tool (#280)
* refactor: replace persistent subagents with ephemeral spawn tool (#subagent)

- Drop subagents table, remove all persistent subagent infrastructure
- Add 'subagent' session type with parent_session_id on bot_sessions
- Rewrite subagent tool as single 'spawn' tool with parallel execution
- Create system_subagent.md prompt, add _subagent.md include for chat
- Limit subagent tools to file, exec, web_search, web_fetch only
- Merge subagent token usage into parent chat session in reporting
- Remove frontend subagent management page, update chat UI for spawn
- Fix UTF-8 truncation in session title, fix query not passed to agent

* refactor: remove history message page
2026-03-22 19:03:28 +08:00

277 lines
9.0 KiB
Go

package tools
import (
"context"
"errors"
"log/slog"
"math"
"strconv"
"strings"
sdk "github.com/memohai/twilight-ai/sdk"
"github.com/memohai/memoh/internal/email"
)
type EmailProvider struct {
logger *slog.Logger
service *email.Service
manager *email.Manager
}
func NewEmailProvider(log *slog.Logger, service *email.Service, manager *email.Manager) *EmailProvider {
return &EmailProvider{
logger: log.With(slog.String("tool", "email")),
service: service,
manager: manager,
}
}
func (p *EmailProvider) Tools(_ context.Context, session SessionContext) ([]sdk.Tool, error) {
if session.IsSubagent {
return nil, nil
}
sess := session
return []sdk.Tool{
{
Name: "list_email_accounts", Description: "List the email accounts (provider bindings) configured for this bot, including provider IDs, email addresses, and permissions.",
Parameters: emptyObjectSchema(),
Execute: func(ctx *sdk.ToolExecContext, _ any) (any, error) {
return p.execListAccounts(ctx.Context, sess)
},
},
{
Name: "send_email", Description: "Send an email via the bot's configured email provider. Requires write permission.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"to": map[string]any{"type": "string", "description": "Recipient email address(es), comma-separated"},
"subject": map[string]any{"type": "string", "description": "Email subject"},
"body": map[string]any{"type": "string", "description": "Email body content"},
"html": map[string]any{"type": "boolean", "description": "Whether body is HTML (default false)"},
"provider_id": map[string]any{"type": "string", "description": "Email provider ID to send from (optional, uses default if omitted)"},
},
"required": []string{"to", "subject", "body"},
},
Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) {
return p.execSendEmail(ctx.Context, sess, inputAsMap(input))
},
},
{
Name: "list_email", Description: "List emails from the mailbox (newest first). Supports pagination. Requires read permission.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"page": map[string]any{"type": "integer", "description": "Page number, 0-based (default 0 = newest)"},
"page_size": map[string]any{"type": "integer", "description": "Emails per page (default 20)"},
"provider_id": map[string]any{"type": "string", "description": "Email provider ID (optional, uses first readable binding)"},
},
},
Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) {
return p.execListEmails(ctx.Context, sess, inputAsMap(input))
},
},
{
Name: "read_email", Description: "Read the full content of an email by its UID. Requires read permission.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"uid": map[string]any{"type": "integer", "description": "The email UID from email_list results"},
"provider_id": map[string]any{"type": "string", "description": "Email provider ID (optional, uses first readable binding)"},
},
"required": []string{"uid"},
},
Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) {
return p.execReadEmail(ctx.Context, sess, inputAsMap(input))
},
},
}, nil
}
func (p *EmailProvider) getBindings(ctx context.Context, botID string) ([]email.BindingResponse, error) {
bindings, err := p.service.ListBindings(ctx, botID)
if err != nil || len(bindings) == 0 {
return nil, errors.New("no email binding configured for this bot")
}
return bindings, nil
}
func (p *EmailProvider) execListAccounts(ctx context.Context, session SessionContext) (any, error) {
botID := strings.TrimSpace(session.BotID)
if botID == "" {
return nil, errors.New("bot_id is required")
}
bindings, err := p.getBindings(ctx, botID)
if err != nil {
return nil, err
}
accounts := make([]map[string]any, 0, len(bindings))
for _, b := range bindings {
accounts = append(accounts, map[string]any{
"provider_id": b.EmailProviderID, "email_address": b.EmailAddress,
"can_read": b.CanRead, "can_write": b.CanWrite, "can_delete": b.CanDelete,
})
}
return map[string]any{"accounts": accounts}, nil
}
func (p *EmailProvider) execSendEmail(ctx context.Context, session SessionContext, args map[string]any) (any, error) {
botID := strings.TrimSpace(session.BotID)
if botID == "" {
return nil, errors.New("bot_id is required")
}
bindings, err := p.getBindings(ctx, botID)
if err != nil {
return nil, err
}
binding := resolveWriteBinding(bindings, StringArg(args, "provider_id"))
if binding == nil {
return nil, errors.New("email write permission denied or provider not found")
}
toRaw := StringArg(args, "to")
subject := StringArg(args, "subject")
body := StringArg(args, "body")
isHTML, _, _ := BoolArg(args, "html")
if toRaw == "" || subject == "" || body == "" {
return nil, errors.New("to, subject, and body are required")
}
var toList []string
for _, addr := range strings.Split(toRaw, ",") {
addr = strings.TrimSpace(addr)
if addr != "" {
toList = append(toList, addr)
}
}
messageID, err := p.manager.SendEmail(ctx, botID, binding.EmailProviderID, email.OutboundEmail{
To: toList, Subject: subject, Body: body, HTML: isHTML,
})
if err != nil {
return nil, err
}
return map[string]any{"message_id": messageID, "status": "sent"}, nil
}
func (p *EmailProvider) execListEmails(ctx context.Context, session SessionContext, args map[string]any) (any, error) {
botID := strings.TrimSpace(session.BotID)
if botID == "" {
return nil, errors.New("bot_id is required")
}
bindings, err := p.getBindings(ctx, botID)
if err != nil {
return nil, err
}
binding := resolveReadBinding(bindings, StringArg(args, "provider_id"))
if binding == nil {
return nil, errors.New("email read permission denied or provider not found")
}
providerName, config, err := p.service.ProviderConfig(ctx, binding.EmailProviderID)
if err != nil {
return nil, err
}
config = ensureProviderID(config, binding.EmailProviderID)
reader, err := p.service.Registry().GetMailboxReader(providerName)
if err != nil {
return nil, errors.New("mailbox listing not supported for this provider")
}
page, _, _ := IntArg(args, "page")
pageSize, _, _ := IntArg(args, "page_size")
if pageSize <= 0 {
pageSize = 20
}
emails, total, err := reader.ListMailbox(ctx, config, page, pageSize)
if err != nil {
return nil, err
}
summaries := make([]map[string]any, 0, len(emails))
for _, item := range emails {
summaries = append(summaries, map[string]any{
"uid": item.MessageID, "from": item.From, "subject": item.Subject, "received_at": item.ReceivedAt,
})
}
return map[string]any{"emails": summaries, "total": total, "page": page}, nil
}
func (p *EmailProvider) execReadEmail(ctx context.Context, session SessionContext, args map[string]any) (any, error) {
botID := strings.TrimSpace(session.BotID)
if botID == "" {
return nil, errors.New("bot_id is required")
}
bindings, err := p.getBindings(ctx, botID)
if err != nil {
return nil, err
}
binding := resolveReadBinding(bindings, StringArg(args, "provider_id"))
if binding == nil {
return nil, errors.New("email read permission denied or provider not found")
}
uidRaw, ok, _ := IntArg(args, "uid")
if !ok || uidRaw <= 0 {
uidStr := StringArg(args, "uid")
if uidStr != "" {
parsed, _ := strconv.Atoi(uidStr)
uidRaw = parsed
}
}
if uidRaw <= 0 {
return nil, errors.New("uid is required")
}
if uidRaw > math.MaxUint32 {
return nil, errors.New("uid out of range")
}
providerName, config, err := p.service.ProviderConfig(ctx, binding.EmailProviderID)
if err != nil {
return nil, err
}
config = ensureProviderID(config, binding.EmailProviderID)
reader, err := p.service.Registry().GetMailboxReader(providerName)
if err != nil {
return nil, errors.New("mailbox reading not supported for this provider")
}
item, err := reader.ReadMailbox(ctx, config, uint32(uidRaw)) //nolint:gosec // bounds checked above
if err != nil {
return nil, err
}
return map[string]any{
"uid": item.MessageID, "from": item.From, "to": item.To,
"subject": item.Subject, "body": item.BodyText, "received_at": item.ReceivedAt,
}, nil
}
func resolveReadBinding(bindings []email.BindingResponse, providerID string) *email.BindingResponse {
for i := range bindings {
if !bindings[i].CanRead {
continue
}
if providerID == "" || bindings[i].EmailProviderID == providerID {
return &bindings[i]
}
}
return nil
}
func resolveWriteBinding(bindings []email.BindingResponse, providerID string) *email.BindingResponse {
for i := range bindings {
if !bindings[i].CanWrite {
continue
}
if providerID == "" || bindings[i].EmailProviderID == providerID {
return &bindings[i]
}
}
return nil
}
func ensureProviderID(config map[string]any, providerID string) map[string]any {
if config == nil {
config = make(map[string]any)
} else {
copied := make(map[string]any, len(config)+1)
for k, v := range config {
copied[k] = v
}
config = copied
}
config["_provider_id"] = providerID
return config
}