// Package automations implements the Cyrene v2 automation engine and reminder // scheduler (DevDocs/design/04-运行时.md §8.5). It is the "how she does things on // her own" layer: schedules and event rules fire a named action, the action runs // (either a plain function, a registered tool, or a prompt through the AI // loop/agent pipeline), and every outcome is published back to the bus so upper // layers can react or push notifications. // // The package is split into five files: // // - automations.go — shared types: Config, Deps, the Pipeline abstraction, the // published Result and the sentinel errors. // - scheduler.go — the cron-driven Scheduler (robfig/cron/v3). // - rule.go — the Rule model and the event-driven Engine. // - action.go — the Action interface plus func/tool/AI implementations. // - reminder.go — the time-based ReminderStore. // - service.go — the Service that composes the three for lifecycle control. // // Execution model: every trigger (cron fire, event match, reminder due) resolves // to the same runAction, which executes the Action under an optional deadline and // publishes a Result to the "automation.result" subject. This keeps reporting and // logging consistent across all trigger kinds (DevDocs/design/04-运行时.md §8.5: // task output returns to the message queue/agent). // // Identity and capability boundaries (Devedocs global red lines): automation // never decides who is allowed to do anything. It only fires named actions; // authorization stays in the channel/policy layer. package automations import ( "context" "errors" "fmt" "io" "log/slog" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // Bus subjects published by the automations engine. Upper layers subscribe to // these to observe outcomes or push notifications. const ( // SubjectResult is the subject every executed automation publishes its // Result to. SubjectResult = "automation.result" // SubjectTrigger is the subject a due Reminder is published to. Upper layers // subscribe to it to surface the reminder to the user. SubjectTrigger = "reminder.trigger" ) // Sentinel errors returned by this package. Use errors.Is to test for them. var ( // ErrNilBus is returned when a constructor receives a nil bus. ErrNilBus = errors.New("automations: nil bus") // ErrNilAction is returned when an action is nil or unnamed. ErrNilAction = errors.New("automations: nil action") // ErrInvalidRule is returned when a rule is malformed. ErrInvalidRule = errors.New("automations: invalid rule") // ErrInvalidCron is returned when a cron expression fails to parse. ErrInvalidCron = errors.New("automations: invalid cron expression") // ErrNotFound is returned when a rule or reminder is missing. ErrNotFound = errors.New("automations: not found") // ErrDuplicate is returned when a rule or reminder id is already registered. ErrDuplicate = errors.New("automations: already exists") // ErrNoPipeline is returned when an AI action is built without a pipeline. ErrNoPipeline = errors.New("automations: no AI pipeline configured") ) // Config configures the automations engine. type Config struct { // Location is the timezone used for cron schedules and reminder firing. Nil // uses time.Local. Location *time.Location // WithSeconds enables 6-field cron specs (with a leading seconds field) in // addition to the standard 5-field format. WithSeconds bool // ActionTimeout bounds a single action execution. Non-positive disables the // deadline. ActionTimeout time.Duration // ReminderInterval is how often the reminder loop polls for due reminders. // Non-positive selects defaultReminderInterval. ReminderInterval time.Duration } // Deps is the injected dependency set for the automations engine. type Deps struct { // Bus is the in-process event bus. Required. Bus bus.Bus // Loop is the AI pipeline (the agent loop). When set, an AI action runs // through it. Agent is used only when Loop is nil. Loop *loop.Loop // Agent is the single-step agent, used as the AI pipeline when Loop is nil. Agent *agent.Agent // Log is the base logger. A nil logger is replaced with a discard logger // tagged with source "services/automations". Log *slog.Logger // Now supplies the current time for reminders. Nil uses time.Now. Now func() time.Time } // Pipeline is the AI-execution surface an automation action runs through. It is // satisfied by *loop.Loop directly, so a real pipeline can be injected without // an adapter. Tests inject a fake to observe the prompt and return canned text. type Pipeline interface { Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) } // AgentPipeline adapts an *agent.Agent to Pipeline by running a single step and // returning its text as the result. It is used when only an agent (not a full // loop) is injected. type AgentPipeline struct { Agent *agent.Agent } // Run runs one agent step and returns its text as the loop-style Result. func (a *AgentPipeline) Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) { if a.Agent == nil { return nil, fmt.Errorf("automations: agent pipeline: %w", ErrNoPipeline) } sr, err := a.Agent.RunStep(ctx, sessionID, messages) if err != nil { return nil, fmt.Errorf("automations: agent pipeline run: %w", err) } return &loop.Result{Text: sr.Text, StopReason: sr.StopReason, Steps: 1}, nil } // pipeline returns the resolved AI pipeline from deps, or nil when neither a // loop nor an agent is configured. func (d Deps) pipeline() Pipeline { if d.Loop != nil { return d.Loop } if d.Agent != nil { return &AgentPipeline{Agent: d.Agent} } return nil } // AutomationRecord is the read-only summary of one automation or reminder, // returned by Service.List for the admin panel. Kind is "cron", "event" or // "reminder"; Cron carries the cron expression for the cron kind; LastRun and // NextRun are zero times when the value is unknown (typically before Start). type AutomationRecord struct { ID string Name string Kind string Cron string Enabled bool LastRun time.Time NextRun time.Time } // Result is the structured outcome of an executed automation, published to // SubjectResult. Upper layers subscribe to it to react or push notifications. type Result struct { // Kind classifies the trigger: "schedule", "event" or "reminder". Kind string `json:"kind"` // RuleID identifies the schedule/rule/reminder that fired. RuleID string `json:"rule_id"` // Action is the action name that ran. Action string `json:"action"` // Payload is the trigger payload (nil for a cron fire). Payload any `json:"payload"` // Output is the action's returned value, or nil on error. Output any `json:"output"` // Error carries the action's error message when the run failed. Error string `json:"error,omitempty"` } // sourcedLog returns a base logger tagged with the module source // "services/automations", replacing a nil logger with a discard one so callers // can always log safely. func sourcedLog(l *slog.Logger) *slog.Logger { if l == nil { l = slog.New(slog.NewTextHandler(io.Discard, nil)) } return logger.WithSource(l, "services/automations") } // resolveNow returns f when non-nil, otherwise time.Now. func resolveNow(f func() time.Time) func() time.Time { if f == nil { return time.Now } return f } // payloadKind 返回触发 payload 的 Go 类型名,用于日志里替代 payload 本身。 // 类型名是代码事实(*gateway.ChatEvent 之类),正文才是敏感物 —— 排查「这条路 // 上跑的是什么消息」靠类型名与体积够了,不需要内容。 // // 它**不**调用 payload 的 String()/Error():那正是把正文请进日志的常见口子。 func payloadKind(payload any) string { if payload == nil { return "nil" } return fmt.Sprintf("%T", payload) }