package persona // Phase 6 — Builtin Agent loader. // // Why: Phase 1-5 introduced the Agent noun, runtime, triggers, // CommandBinding, and ChatBot bridge — but every Agent in production // was either (a) a wrapper auto-migrated from a triggered Skill, or // (b) admin-created via `.agent new`. There were no SHIPPED Agents // authored as builtins. Phase 6 adds an idempotent boot-time loader so // the repo can ship canonical Agent definitions (alongside the // existing skills//skill.yml builtins) without manual admin // creation per deploy. // // What: scans `/agents/*/agent.yml`, decodes each YAML // into an Agent, and upserts via Storage.SaveAgent under the deterministic // system owner ID "builtin". Skill-palette entries are validated AT LOAD // TIME against the live skills storage; missing skills warn but do not // fail the load (the skill might arrive later via a different code // path, and runtime resolution happens at invocation time anyway). // // Bypass note (v3 lesson, mirrored): like skills.LoadBuiltins, this // loader writes via Storage.SaveAgent directly. There is no agents // equivalent of SaveUserSkill's save-time gates today (Phase 1-5 don't // have authoring requirements on agents), but if such gates appear in // future phases, this loader MUST keep bypassing them — builtins are // trusted infrastructure. // // Test: pkg/logic/agents/builtin_loader_test.go covers happy path, // idempotent re-load, missing-skill warn capture, and malformed YAML // surfaced as a per-bundle warning (not a fatal error). import ( "context" "errors" "fmt" "io/fs" "log/slog" "path" "strings" "time" "github.com/google/uuid" "gopkg.in/yaml.v3" ) // BuiltinAgentOwnerID is the deterministic system owner ID used for // every Agent created by LoadBuiltinAgents. Chosen as a non-empty // string so the (owner_id, name) unique index distinguishes builtins // from any user-authored Agent (Discord member IDs are numeric, so // "builtin" cannot collide). The skills builtin loader uses owner_id="" // instead; the two systems are independent storage scopes — there's // no need for consistency here. const BuiltinAgentOwnerID = "builtin" // SkillExistenceChecker is the narrow surface LoadBuiltinAgents needs // to validate skill_palette entries at load time. Production wires // skills.Storage which already exposes ListByName for non-owner-scoped // lookups. nil means "skip palette validation" (tests that don't care). // // Why a separate narrow interface (vs importing skills.Storage): // agents already transitively depends on skills via migrate_from_skills, // but the loader only needs "does a skill with this name exist // somewhere?" — a single Boolean. Keeping the interface narrow makes // the loader testable without a full skills storage stub. type SkillExistenceChecker interface { // SkillExistsByName reports whether at least one skill row has the // given name across any owner (builtins live under owner_id=""; // users own their own rows; the loader's validation just wants // "does ANY row exist with this name?"). SkillExistsByName(ctx context.Context, name string) (bool, error) } // LoadBuiltinAgents discovers and seeds builtin Agents from `builtinsDir`. // `builtinsDir` is the root that contains an `agents/` subdirectory; // per-agent YAML lives at `agents//agent.yml`. Returns the count // of agents seeded or updated (skipped rows do not contribute to the // count). Returns nil error when the agents/ directory is absent — a // deployment without any builtin agents is valid; the loader is then a // no-op. // // Idempotency contract: existing Agent rows (matched by (owner_id="builtin", // name)) are UPDATED to the freshly-parsed YAML on each boot. ID + // CreatedAt are preserved; UpdatedAt is refreshed. User clones of a // builtin Agent (different owner_id, same name) are NEVER touched — // the loader only writes to (owner_id="builtin", name) rows. // // `skillChecker` may be nil; when non-nil, each SkillPalette entry is // looked up and a WARN log emitted (with the agent + missing skill // name) for absent references. The Agent row is still seeded with the // palette intact — runtime resolution at invocation time is the // authoritative gate. func LoadBuiltinAgents(ctx context.Context, store Storage, builtinsDir fs.FS, skillChecker SkillExistenceChecker) (int, error) { if store == nil { return 0, errors.New("agents.LoadBuiltinAgents: nil store") } if builtinsDir == nil { return 0, errors.New("agents.LoadBuiltinAgents: nil builtinsDir FS") } entries, err := fs.ReadDir(builtinsDir, "agents") if err != nil { // Missing agents/ directory is benign — a deployment may not // ship any builtins. Other errors propagate so a permission / // IO problem surfaces loudly. if errors.Is(err, fs.ErrNotExist) { slog.Info("agents: no builtin agents directory", "path", "agents") return 0, nil } return 0, fmt.Errorf("agents: read agents dir: %w", err) } // Phase 1: parse all agent manifests into a map keyed by name. // The map is needed so extends references can be resolved before // any agent is upserted. type parsedEntry struct { agent *Agent dir string } parsed := make(map[string]*parsedEntry) var parseOrder []string // preserve FS iteration order for deterministic upsert var scanned, failed int for _, entry := range entries { if !entry.IsDir() { continue } manifestPath := path.Join("agents", entry.Name(), "agent.yml") data, readErr := fs.ReadFile(builtinsDir, manifestPath) if readErr != nil { slog.Debug("agents: skipping (no agent.yml)", "dir", entry.Name(), "error", readErr) continue } scanned++ ag, parseErr := decodeAgentManifest(data) if parseErr != nil { slog.Warn("agents: invalid agent.yml", "dir", entry.Name(), "error", parseErr) failed++ continue } parsed[ag.Name] = &parsedEntry{agent: ag, dir: entry.Name()} parseOrder = append(parseOrder, ag.Name) } // Phase 2: resolve extends references. Only single-level is // supported — chains (A extends B extends C) are rejected. for _, name := range parseOrder { pe := parsed[name] ag := pe.agent if ag.Extends == "" { continue } parent, ok := parsed[ag.Extends] if !ok { slog.Warn("agents: extends references unknown agent", "agent", ag.Name, "extends", ag.Extends) failed++ delete(parsed, name) continue } if parent.agent.Extends != "" { slog.Warn("agents: extends chain not supported — parent also uses extends", "agent", ag.Name, "extends", ag.Extends, "parent_extends", parent.agent.Extends) failed++ delete(parsed, name) continue } if ag.Extends == ag.Name { slog.Warn("agents: agent extends itself", "agent", ag.Name) failed++ delete(parsed, name) continue } resolveExtends(ag, parent.agent) } // Phase 3: palette validation + upsert. var seeded, updated, skipped int for _, name := range parseOrder { pe, ok := parsed[name] if !ok { continue // removed during extends resolution } ag := pe.agent if skillChecker != nil { for _, sk := range ag.SkillPalette { ok, lookupErr := skillChecker.SkillExistsByName(ctx, sk) if lookupErr != nil { slog.Warn("agents: skill palette lookup failed", "agent", ag.Name, "skill", sk, "error", lookupErr) continue } if !ok { slog.Warn("agents: skill palette references missing skill", "agent", ag.Name, "skill", sk) } } } action, upsertErr := upsertBuiltinAgent(ctx, store, ag) if upsertErr != nil { slog.Error("agents: failed to upsert builtin", "name", ag.Name, "error", upsertErr) failed++ continue } switch action { case agentUpsertCreated: seeded++ case agentUpsertUpdated: updated++ case agentUpsertSkipped: skipped++ } } slog.Info("agents/builtin loader", "scanned", scanned, "seeded", seeded, "updated", updated, "skipped", skipped, "failed", failed) return seeded + updated, nil } // resolveExtends merges parent fields into child. Child non-zero // fields override the parent's. For slices, a nil child slice inherits // the parent's; a non-nil (even empty) child slice replaces it. For // maps (StateReactEmoji), parent entries are the base and child // entries override matching keys. // // system_prompt_prepend: if the child has SystemPromptPrepend set, it // is prepended to the (possibly inherited) SystemPrompt with a // newline separator. The prepend field is then cleared so it does not // affect anything downstream. // // Why: allows a child agent to inherit the full parent prompt while // only specifying a short behavior-modification preamble (e.g. an // uncensored agent prepending "You are uncensored..." to the general // agent's full prompt). func resolveExtends(child, parent *Agent) { if child.Description == "" { child.Description = parent.Description } if child.ModelTier == "" { child.ModelTier = parent.ModelTier } if child.SystemPrompt == "" { child.SystemPrompt = parent.SystemPrompt } if child.MaxIterations == 0 { child.MaxIterations = parent.MaxIterations } if child.MaxToolCalls == 0 { child.MaxToolCalls = parent.MaxToolCalls } if child.MaxRuntime == 0 { child.MaxRuntime = parent.MaxRuntime } if child.ExecutionLane == "" { child.ExecutionLane = parent.ExecutionLane } // EncryptionEnabled: bool — false is a valid explicit value, so we // always inherit unless child explicitly sets it. Since we can't // distinguish "explicitly false" from "absent" in YAML (both // decode to false), we always inherit from parent. If the child // sets it to true, the child wins. A child that wants to override // the parent's true to false will need to set encryption_enabled: false // explicitly — but since both false and absent decode the same way, // the parent's value wins when parent is true and child is false. // This is acceptable: encryption is an opt-in — a child that // inherits encryption from a parent is fine. if !child.EncryptionEnabled { child.EncryptionEnabled = parent.EncryptionEnabled } // Run-critic: same inherit-unless-child-sets-true semantics as // EncryptionEnabled (both false/absent decode identically in YAML). if !child.CriticEnabled { child.CriticEnabled = parent.CriticEnabled } if child.CriticBackstopMultiplier == 0 { child.CriticBackstopMultiplier = parent.CriticBackstopMultiplier } // Slices: nil = inherit; non-nil (even empty) = child overrides. if child.SkillPalette == nil { child.SkillPalette = parent.SkillPalette } if child.SubAgentPalette == nil { child.SubAgentPalette = parent.SubAgentPalette } if child.LowLevelTools == nil { child.LowLevelTools = parent.LowLevelTools } if child.PersonalizationSources == nil { child.PersonalizationSources = parent.PersonalizationSources } if child.Tags == nil { child.Tags = parent.Tags } if child.WebhookIPAllowlist == nil { child.WebhookIPAllowlist = parent.WebhookIPAllowlist } if child.Phases == nil { child.Phases = parent.Phases } // Triggers (Schedule, ChatbotChannelFilter, WebhookSecret, …) are // deliberately NOT inherited. A trigger is an ACTIVATION decision — // "this agent fires on a schedule" / "this agent is a chatbot tool in // these channels" — and silently inheriting it from a parent persona // is a behavioural surprise: `uncensored extends general` would inherit // general's `chatbot_channel_filter: "none"` (match-every-channel) and // surface the unfiltered model as a direct chatbot tool everywhere the // instant agents.triggers.enabled flips on. A child that wants a trigger // must declare it explicitly. (Persona, caps, palette, and tools are // inherited above — those are capability, not activation.) // DefaultEmoji: child wins if set; otherwise inherit. if child.DefaultEmoji == "" { child.DefaultEmoji = parent.DefaultEmoji } // Maps: merge — parent is the base, child entries override. if child.StateReactEmoji == nil && parent.StateReactEmoji != nil { child.StateReactEmoji = make(map[string]string, len(parent.StateReactEmoji)) for k, v := range parent.StateReactEmoji { child.StateReactEmoji[k] = v } } else if parent.StateReactEmoji != nil { merged := make(map[string]string, len(parent.StateReactEmoji)+len(child.StateReactEmoji)) for k, v := range parent.StateReactEmoji { merged[k] = v } for k, v := range child.StateReactEmoji { merged[k] = v } child.StateReactEmoji = merged } // SystemPromptPrepend: prepend to the (now resolved) SystemPrompt. if child.SystemPromptPrepend != "" { child.SystemPrompt = child.SystemPromptPrepend + "\n\n" + child.SystemPrompt child.SystemPromptPrepend = "" // consumed } // Clear Extends — the resolution is complete, the persisted agent // is standalone. child.Extends = "" } // agentUpsertAction reports what upsertBuiltinAgent did. Exported only // to the test in this package; the loader's public surface returns a // count, not a per-row action. type agentUpsertAction int const ( agentUpsertCreated agentUpsertAction = iota agentUpsertUpdated agentUpsertSkipped // reserved; current loader never returns this — every parse-OK row is upserted ) // upsertBuiltinAgent looks up an existing (BuiltinAgentOwnerID, name) // row. If absent, inserts a new row with a freshly-minted UUID. // Otherwise updates the existing row in place, preserving ID + CreatedAt. // // Why not version-skip like skills.upsertBuiltin: the Agent struct has // a Version int field but it's a monotonic counter, not a semver // string for change detection. Agent YAML doesn't carry a "version" // at the wire shape; every boot writes the latest YAML content, // trusting the YAML file in-repo IS the source of truth. The Agent's // internal Version int auto-increments on each loader pass so admin // inspection (`.agent show`) reveals "how many times has the loader // touched this row". func upsertBuiltinAgent(ctx context.Context, store Storage, fresh *Agent) (agentUpsertAction, error) { existing, err := store.GetAgentByName(ctx, BuiltinAgentOwnerID, fresh.Name) if err != nil && !errors.Is(err, ErrNotFound) { return agentUpsertCreated, fmt.Errorf("lookup builtin agent %q: %w", fresh.Name, err) } if errors.Is(err, ErrNotFound) { fresh.ID = uuid.New().String() fresh.OwnerID = BuiltinAgentOwnerID fresh.AuthoredBy = BuiltinAgentOwnerID if fresh.Version == 0 { fresh.Version = 1 } now := time.Now() fresh.CreatedAt = now fresh.UpdatedAt = now if saveErr := store.SaveAgent(ctx, fresh); saveErr != nil { return agentUpsertCreated, saveErr } slog.Info("agents: created builtin", "name", fresh.Name, "id", fresh.ID) return agentUpsertCreated, nil } // Update in place. Preserve ID, OwnerID, AuthoredBy, CreatedAt. // Bump Version so admins can see "the loader has touched this N // times" — useful when investigating a builtin that was // hand-edited via the future web UI and unexpectedly reverted on // next boot. fresh.ID = existing.ID fresh.OwnerID = BuiltinAgentOwnerID fresh.AuthoredBy = BuiltinAgentOwnerID fresh.Version = existing.Version + 1 fresh.CreatedAt = existing.CreatedAt fresh.UpdatedAt = time.Now() // Carry forward operator/scheduler-owned fields that the manifest // never sets (decodeAgentManifest leaves these zero by design — a // secret in-repo would be a credential leak). Without this, every // boot CLOBBERS an operator-armed webhook secret + signature flag // back to empty/false and nukes the scheduler's next-fire cursor, so // a scheduled or webhook-armed builtin silently breaks on each deploy. fresh.WebhookSecret = existing.WebhookSecret fresh.WebhookSignatureRequired = existing.WebhookSignatureRequired fresh.NextRunAt = existing.NextRunAt fresh.LastScheduledRunAt = existing.LastScheduledRunAt if saveErr := store.SaveAgent(ctx, fresh); saveErr != nil { return agentUpsertUpdated, saveErr } slog.Info("agents: updated builtin", "name", fresh.Name, "id", fresh.ID, "version", fresh.Version) return agentUpsertUpdated, nil } // builtinAgentManifest is the YAML wire format for agents//agent.yml. // The schema is intentionally a SUBSET of the Agent struct — future // fields can be added without breaking existing manifests so long as // we keep KnownFields(true) decoding (so a typo on a key surfaces as // an error rather than silently dropping data). // // See pkg/logic/agents/CLAUDE.md for the schema reference. type builtinAgentManifest struct { Name string `yaml:"name"` Description string `yaml:"description"` ModelTier string `yaml:"model_tier"` SystemPrompt string `yaml:"system_prompt"` SystemPromptPrepend string `yaml:"system_prompt_prepend"` MaxIterations int `yaml:"max_iterations"` MaxToolCalls int `yaml:"max_tool_calls"` MaxRuntimeSeconds int `yaml:"max_runtime_seconds"` ExecutionLane string `yaml:"execution_lane"` EncryptionEnabled bool `yaml:"encryption_enabled"` // Run-critic two-tier timeout. CriticEnabled flips MaxRuntime from a // hard kill into a soft trigger; CriticBackstopMultiplier (0 => convar // default 6×) sets the hard backstop = MaxRuntime × multiplier. CriticEnabled bool `yaml:"critic_enabled"` CriticBackstopMultiplier float64 `yaml:"critic_backstop_multiplier"` // Extends names a parent agent whose fields are inherited. The // child's non-zero fields override the parent; nil/empty slices // inherit the parent's. Maps (state_react) are merged — child // entries override parent entries with the same key. Only single- // level extends is supported (no chains). Extends string `yaml:"extends"` SkillPalette []string `yaml:"skill_palette"` SubAgentPalette []string `yaml:"sub_agent_palette"` LowLevelTools []string `yaml:"low_level_tools"` PersonalizationSources []string `yaml:"personalization_sources"` // Triggers — builtin agents typically don't ship with triggers // (admins flip these on per-deployment), but the keys are accepted // so a sufficiently sophisticated builtin (e.g. a scheduled "weekly // digest" agent) can ship triggers in-repo. Default empty. Schedule string `yaml:"schedule"` WebhookIPAllowlist []string `yaml:"webhook_ip_allowlist"` ChatbotChannelFilter string `yaml:"chatbot_channel_filter"` DefaultEmoji string `yaml:"default_emoji"` StateReact map[string]string `yaml:"state_react"` Tags []string `yaml:"tags"` // Pipeline phases — when non-empty, the executor runs the // sequential phase runner instead of the single agent loop. Phases []builtinAgentPhaseManifest `yaml:"phases"` } // builtinAgentPhaseManifest is the YAML wire format for a single // phases list entry in agents//agent.yml. Maps 1:1 to // AgentPhase at decode time. type builtinAgentPhaseManifest struct { Name string `yaml:"name"` SystemPrompt string `yaml:"system_prompt"` ModelTier string `yaml:"model_tier"` MaxIter int `yaml:"max_iter"` Tools []string `yaml:"tools"` Optional bool `yaml:"optional"` FallbackMessage string `yaml:"fallback_message"` IsRunFunc bool `yaml:"is_run_func"` } // decodeAgentManifest parses an agent.yml bundle into a domain Agent. // Uses KnownFields(true) so a typo'd key surfaces as a parse error // rather than silently dropping the value. // // What this method does NOT set: // - ID (loader mints UUID on insert / preserves existing on update) // - OwnerID + AuthoredBy (loader sets to BuiltinAgentOwnerID) // - Version (loader increments on update) // - CreatedAt + UpdatedAt (loader stamps) // - WebhookSecret (operator generates via admin tooling at deploy // time — shipping a secret in-repo would be a credential leak) // - NextRunAt + LastScheduledRunAt (scheduler bookkeeping; nil at // load time, populated on first scheduled fire) // - WebhookSignatureRequired (application-layer default applies on // first save; a `default:true` GORM tag would substitute on every // write — see the v8 lesson on this exact trap) func decodeAgentManifest(data []byte) (*Agent, error) { var m builtinAgentManifest dec := yaml.NewDecoder(strings.NewReader(string(data))) dec.KnownFields(true) if err := dec.Decode(&m); err != nil { return nil, fmt.Errorf("decode agent.yml: %w", err) } if strings.TrimSpace(m.Name) == "" { return nil, errors.New("agent.yml: missing required field 'name'") } // system_prompt is required UNLESS the agent uses extends (the parent // will supply it) or system_prompt_prepend (the prepend will be // combined with the parent's system_prompt after extends resolution). if strings.TrimSpace(m.SystemPrompt) == "" && strings.TrimSpace(m.Extends) == "" && strings.TrimSpace(m.SystemPromptPrepend) == "" { return nil, errors.New("agent.yml: missing required field 'system_prompt'") } // Convert YAML phase manifests to domain AgentPhase structs. var phases []AgentPhase for _, pm := range m.Phases { if strings.TrimSpace(pm.Name) == "" { return nil, errors.New("agent.yml: phase missing required field 'name'") } phases = append(phases, AgentPhase{ Name: strings.TrimSpace(pm.Name), SystemPrompt: pm.SystemPrompt, ModelTier: strings.TrimSpace(pm.ModelTier), MaxIter: pm.MaxIter, Tools: pm.Tools, Optional: pm.Optional, FallbackMessage: pm.FallbackMessage, IsRunFunc: pm.IsRunFunc, }) } ag := &Agent{ Name: strings.TrimSpace(m.Name), Description: m.Description, Extends: strings.TrimSpace(m.Extends), SystemPromptPrepend: m.SystemPromptPrepend, ModelTier: strings.TrimSpace(m.ModelTier), SystemPrompt: m.SystemPrompt, MaxIterations: m.MaxIterations, MaxToolCalls: m.MaxToolCalls, MaxRuntime: time.Duration(m.MaxRuntimeSeconds) * time.Second, ExecutionLane: strings.TrimSpace(m.ExecutionLane), EncryptionEnabled: m.EncryptionEnabled, CriticEnabled: m.CriticEnabled, CriticBackstopMultiplier: m.CriticBackstopMultiplier, SkillPalette: m.SkillPalette, SubAgentPalette: m.SubAgentPalette, LowLevelTools: m.LowLevelTools, PersonalizationSources: m.PersonalizationSources, Schedule: strings.TrimSpace(m.Schedule), WebhookIPAllowlist: m.WebhookIPAllowlist, ChatbotChannelFilter: strings.TrimSpace(m.ChatbotChannelFilter), DefaultEmoji: m.DefaultEmoji, StateReactEmoji: m.StateReact, Tags: m.Tags, Phases: phases, } return ag, nil }