P4: persona noun — Agent + ToRunnable bridge + Memory store
executus CI / test (pull_request) Failing after 58s
Adversarial Review (Gadfly) / review (pull_request) Successful in 20m1s

The headline P4 piece (clean redesign): the Agent persona noun, decoupled from
its Discord shell.
- agent.go/storage.go/builtin_loader.go moved from mort's pkg/logic/agents; the
  Storage seam drops the Discord CommandBindingStorage embedding (a host
  concern). The host-entangled files (commands, chatbot_provider, command-
  binding dispatcher, personalization, system) stay in mort.
- runnable.go: Agent.ToRunnable() lowers a persona into run.RunnableAgent — the
  bridge that lets run.Executor run a persona without importing this battery
  (the inversion of agentexec.Run(*agents.Agent)).
- memory.go: NewMemory() — zero-dep in-process persona Storage (all 11 CRUD +
  trigger-query methods).

Tests: ToRunnable field/phase mapping; Memory round-trip. CI invariant: core
imports ZERO from persona.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:24:18 -04:00
parent 3f14aae032
commit f521c583bd
9 changed files with 1083 additions and 1 deletions
+3 -1
View File
@@ -61,7 +61,9 @@ CORE (majordomo + stdlib):
tools/{web,net,store,compose,meta,comms} generic tools [P3] tools/{web,net,store,compose,meta,comms} generic tools [P3]
BATTERIES (opt-in siblings, each nil-safe + a default): BATTERIES (opt-in siblings, each nil-safe + a default):
persona/ Agent noun + AgentStore seam + yml loader [P4] persona/ Agent noun + Storage seam + builtin loader [P4 ~]
+ ToRunnable() bridge to run.RunnableAgent +
Memory default (host: chatbot/commands/personalization)
skill/ rich Skill + SkillStore seam + toml loader [P4] skill/ rich Skill + SkillStore seam + toml loader [P4]
audit/ run.Audit Sink + Writer + queryable Memory [P4 ✓] audit/ run.Audit Sink + Writer + queryable Memory [P4 ✓]
default (skillaudit Storage iface; GORM stays in mort) default (skillaudit Storage iface; GORM stays in mort)
+1
View File
@@ -6,6 +6,7 @@ require (
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3 gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
+1
View File
@@ -125,6 +125,7 @@ google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6h
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+191
View File
@@ -0,0 +1,191 @@
// Package agents implements the Agent noun: a persisted persona +
// execution spec + palette of skills/sub-agents/low-level tools, with
// optional trigger metadata (schedule, webhook, chatbot channel
// listener) and personalization sources.
//
// Phase 1 scope: this package introduces Agent as a persisted noun
// with CRUD only — no execution path, no palette resolution, no
// trigger handling. See /Users/steve/.claude/plans/serene-churning-micali.md
// for the staged rollout. Later phases add agentexec, agent_invoke,
// trigger dispatch (schedule/webhook/chatbot), and CommandBinding.
//
// The three-layer storage pattern from pkg/logic/storage/CLAUDE.md
// applies — when adding a field to Agent, you MUST update
// pkg/logic/storage/agents.go (gormAgent, agentFromStorage,
// toStorage) or persistence will silently break.
package persona
import "time"
// Agent is the domain definition of an Agent persona + execution spec.
//
// Why: an Agent is the "configured invoker" — model tier + base
// system prompt + a palette of capabilities (skills, sub-agents,
// low-level tools) it may exercise during a run. Where a Skill is a
// reusable parameterised callable (a library function), an Agent is
// the actor with a persistent persona that calls those skills. The
// struct is flat — every field lives on its own column on the
// agents table; JSON columns are used only for variable-length
// collections (palette lists, tags, etc.).
//
// What: identity + persona + execution caps + palette + triggers +
// personalization + UX, all on one struct. Several field families
// (Palette, Triggers, Personalization) are persisted now but NOT
// exercised until later phases — they exist so the schema is stable
// and future phases can light up behaviour without DB migrations.
//
// Test: see pkg/logic/agents/storage_round_trip_test.go for
// Save/Get/GetByName/List/Delete coverage.
type Agent struct {
// Identity
ID string // UUID
Name string // unique per OwnerID; human-friendly identifier
Description string
OwnerID string // Discord member ID
AuthoredBy string // Discord member ID; usually == OwnerID
Version int // monotonic, for future versioning
CreatedAt time.Time
UpdatedAt time.Time
// Extends names the parent agent this agent inherits from. Only used
// during builtin loading — the loader resolves extends references and
// merges fields before persisting. The resolved agent is a standalone
// entity; Extends is NOT persisted in the database. Only single-level
// extends is supported (no chains).
Extends string
// SystemPromptPrepend, when non-empty, is prepended to the system
// prompt (with a trailing newline separator). Used by the extends
// mechanism so a child agent can prepend persona instructions to the
// parent's full system prompt without duplicating it. Like Extends,
// this is resolved at load time and NOT persisted — the final
// SystemPrompt on the persisted Agent already has the prepend applied.
SystemPromptPrepend string
// Persona / execution spec
ModelTier string // "fast" | "standard" | "thinking" | provider/model
SystemPrompt string // base persona prompt (Phase 5 layers personalization on top)
MaxIterations int // 0 → use convar default at execution time
MaxToolCalls int // 0 → use convar default at execution time
MaxRuntime time.Duration // stored as MaxRuntimeNs int64 in GORM (avoid duration-driver flakiness)
ExecutionLane string // lane name; empty = default at execution time
EncryptionEnabled bool // Phase 1 stores the flag; envelope-encryption bridge wires in a later phase
// Run-critic (two-tier timeout). When CriticEnabled is false (the
// default) MaxRuntime is the hard kill, exactly as before. When true,
// MaxRuntime becomes a SOFT trigger: at MaxRuntime the run-critic
// activates and periodically reviews the run; the hard backstop (the
// absolute kill) is MaxRuntime × the multiplier. CriticBackstopMultiplier
// of 0 means "use the convar default" (agents.critic.backstop_multiplier_default,
// default 6×). See pkg/logic/agentcritic.
CriticEnabled bool
CriticBackstopMultiplier float64
// Palette — what this Agent may invoke (Phase 2 reads these).
// Stored as JSON arrays; not exercised by Phase 1 CRUD.
SkillPalette []string // skill IDs/names
SubAgentPalette []string // agent IDs/names
LowLevelTools []string // skilltools registry names
// Personalization (Phase 5 reads these). Each layer name maps to
// a registered PersonalizationProvider that returns text appended
// to SystemPrompt at run time. Empty list = base prompt only.
PersonalizationSources []string
// Triggers — persisted now, NOT dispatched by Phase 1.
//
// Schedule: cron expression or "daily"/"weekly" shorthand. Empty
// = on-demand only. NextRunAt + LastScheduledRunAt are scheduler
// bookkeeping for Phase 3's per-Agent scheduler.
Schedule string
NextRunAt *time.Time
LastScheduledRunAt *time.Time
// Webhook trigger metadata. WebhookSecret empty = inbound
// webhooks disabled. WebhookSignatureRequired defaults true at
// save time (see Skill's lesson: don't store a GORM default on a
// bool where false is a legitimate explicit value — application
// layer is the source of truth).
WebhookSecret string
WebhookSignatureRequired bool
WebhookIPAllowlist []string // CIDR strings; stored as JSON array
// Chatbot trigger metadata. ChatbotChannelFilter names a filter
// registered in pkg/logic/skills' ChannelFilterRegistry — when
// the migrated chatbot dispatches via this Agent, the filter
// gates which channels it listens in.
ChatbotChannelFilter string
// UX
//
// DefaultEmoji is an optional identity emoji for this agent.
// Used as the __start__ fallback and forwarded to the invoking
// Discord message when a parent calls this agent via agent_invoke.
DefaultEmoji string
// StateReactEmoji maps tool names (and reserved keys "__start__",
// "__end__", "__error__") to Discord emoji that the executor
// reacts with as the run progresses. Empty map = no reactions.
StateReactEmoji map[string]string
// Tags is a free-form set of short labels for organisation +
// discovery on the agents list page (Phase 1 admin commands +
// future web UI).
Tags []string
// Phases defines a multi-phase pipeline for this agent. When
// non-empty, the executor runs agentexec's sequential phase runner
// instead of the single agent loop. Empty = single-loop agent.
//
// Phases IS persisted (JSON struct-slice column `phases` on
// gormAgent). It used to be transient — "TOML is the only source of
// truth" — but every production dispatch path resolves the agent from
// the DB, where the dropped Phases meant research / deepresearch
// silently degraded to a single-loop run (the executor's
// `len(a.Phases) > 0` pipeline branch was dead). The builtin loader
// still seeds phases from YAML; persisting them is what makes the
// pipeline branch fire for DB-loaded agents.
Phases []AgentPhase
}
// AgentPhase describes one stage of a multi-phase pipeline in an
// agent definition. Executed directly by agentexec's phase runner
// (pipeline.go) — there is no intermediate execution-spec struct.
//
// What: name + prompt template + model/iteration overrides + tool
// list + optional/fallback flags + IsRunFunc indicator.
//
// Test: see builtin_loader_test.go for YAML round-trip coverage.
type AgentPhase struct {
// Name identifies the phase (e.g., "scout", "plan", "investigate").
Name string
// SystemPrompt for this phase. Supports template variables:
// {{.Query}} for the original query, and {{.<PhaseName>}} for
// prior phase outputs (e.g., {{.scout}}, {{.plan}}).
SystemPrompt string
// ModelTier overrides the agent's ModelTier for this phase.
// Empty = use agent default.
ModelTier string
// MaxIter overrides the agent's MaxIterations for this phase.
// 0 = use agent default.
MaxIter int
// Tools are tool names for this phase only. These are resolved
// from the agent's low-level tools + palette at execution time.
Tools []string
// Optional means errors in this phase don't abort the pipeline.
Optional bool
// FallbackMessage is used when an optional phase fails.
// Default: "(Phase <Name> encountered an error)"
FallbackMessage string
// IsRunFunc indicates this phase is a bare LLM call (no tool
// loop). When true, the executor makes a single model.Complete
// call instead of running the full agent loop.
IsRunFunc bool
}
+570
View File
@@ -0,0 +1,570 @@
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/<name>/skill.yml builtins) without manual admin
// creation per deploy.
//
// What: scans `<builtinsDir>/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/<name>/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/<name>/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/<name>/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
}
+120
View File
@@ -0,0 +1,120 @@
package persona
import (
"context"
"sort"
"sync"
"time"
)
// Memory is a zero-dependency in-process Storage for agent personas — a light
// host (or tests) gets persona persistence with no DB. Mort keeps its
// GORM/MySQL Storage; contrib/store adds a durable SQLite one.
type Memory struct {
mu sync.RWMutex
agents map[string]*Agent // by ID
}
// NewMemory returns an empty in-memory persona Storage.
func NewMemory() *Memory { return &Memory{agents: map[string]*Agent{}} }
var _ Storage = (*Memory)(nil)
func (m *Memory) InitializeAgentStorage(context.Context) error { return nil }
func (m *Memory) SaveAgent(_ context.Context, a *Agent) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *a
m.agents[a.ID] = &cp
return nil
}
func (m *Memory) GetAgent(_ context.Context, id string) (*Agent, error) {
m.mu.RLock()
defer m.mu.RUnlock()
a, ok := m.agents[id]
if !ok {
return nil, ErrNotFound
}
cp := *a
return &cp, nil
}
func (m *Memory) GetAgentByName(_ context.Context, ownerID, name string) (*Agent, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, a := range m.agents {
if a.OwnerID == ownerID && a.Name == name {
cp := *a
return &cp, nil
}
}
return nil, ErrNotFound
}
func (m *Memory) listWhere(keep func(*Agent) bool) []*Agent {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*Agent, 0, len(m.agents))
for _, a := range m.agents {
if keep == nil || keep(a) {
cp := *a
out = append(out, &cp)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (m *Memory) ListAgents(_ context.Context, ownerID string) ([]*Agent, error) {
return m.listWhere(func(a *Agent) bool { return a.OwnerID == ownerID }), nil
}
func (m *Memory) ListAllAgents(context.Context) ([]*Agent, error) {
return m.listWhere(nil), nil
}
func (m *Memory) DeleteAgent(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.agents, id)
return nil
}
func (m *Memory) GetAgentByWebhookSecret(_ context.Context, secret string) (*Agent, error) {
if secret == "" {
return nil, ErrNotFound
}
m.mu.RLock()
defer m.mu.RUnlock()
for _, a := range m.agents {
if a.WebhookSecret == secret {
cp := *a
return &cp, nil
}
}
return nil, ErrNotFound
}
func (m *Memory) ListAgentsByChatbotChannelFilter(context.Context) ([]*Agent, error) {
return m.listWhere(func(a *Agent) bool { return a.ChatbotChannelFilter != "" }), nil
}
func (m *Memory) ListScheduledAgents(_ context.Context, dueBefore time.Time) ([]*Agent, error) {
return m.listWhere(func(a *Agent) bool {
return a.Schedule != "" && a.NextRunAt != nil && !a.NextRunAt.After(dueBefore)
}), nil
}
func (m *Memory) MarkAgentScheduledRun(_ context.Context, agentID string, ranAt, nextAt time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.agents[agentID]
if !ok {
return ErrNotFound
}
a.LastScheduledRunAt = &ranAt
a.NextRunAt = &nextAt
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package persona
import (
"context"
"testing"
"time"
)
func TestToRunnable(t *testing.T) {
a := &Agent{
ID: "id1", Name: "helper", SystemPrompt: "be nice", ModelTier: "fast",
MaxIterations: 5, MaxRuntime: 30 * time.Second,
LowLevelTools: []string{"think"}, SkillPalette: []string{"animate"},
CriticEnabled: true, CriticBackstopMultiplier: 2,
Phases: []AgentPhase{{Name: "p1", ModelTier: "thinking", MaxIter: 3, Tools: []string{"now"}, Optional: true}},
}
r := a.ToRunnable()
if r.ID != "id1" || r.ModelTier != "fast" || r.MaxIterations != 5 || !r.Critic.Enabled {
t.Fatalf("ToRunnable mapping wrong: %+v", r)
}
if len(r.Phases) != 1 || r.Phases[0].MaxIterations != 3 || !r.Phases[0].Optional {
t.Fatalf("phase mapping wrong: %+v", r.Phases)
}
}
func TestMemoryStoreRoundTrip(t *testing.T) {
ctx := context.Background()
m := NewMemory()
a := &Agent{ID: "a1", Name: "n", OwnerID: "o1"}
if err := m.SaveAgent(ctx, a); err != nil {
t.Fatal(err)
}
got, err := m.GetAgent(ctx, "a1")
if err != nil || got.Name != "n" {
t.Fatalf("GetAgent: %v %+v", err, got)
}
byName, err := m.GetAgentByName(ctx, "o1", "n")
if err != nil || byName.ID != "a1" {
t.Fatalf("GetAgentByName: %v %+v", err, byName)
}
list, _ := m.ListAgents(ctx, "o1")
if len(list) != 1 {
t.Fatalf("ListAgents = %d", len(list))
}
}
+37
View File
@@ -0,0 +1,37 @@
package persona
import "gitea.stevedudenhoeffer.com/steve/executus/run"
// ToRunnable lowers an Agent persona into the kernel's run.RunnableAgent DTO —
// the bridge that lets run.Executor run a persona WITHOUT importing this
// battery (the inversion of mort's agentexec.Run(*agents.Agent)). It maps the
// static shape only; per-run personalization, palette resolution, the critic,
// audit, etc. are supplied separately via run.Ports.
func (a *Agent) ToRunnable() run.RunnableAgent {
ra := run.RunnableAgent{
ID: a.ID,
Name: a.Name,
SystemPrompt: a.SystemPrompt,
ModelTier: a.ModelTier,
MaxIterations: a.MaxIterations,
MaxRuntime: a.MaxRuntime,
LowLevelTools: a.LowLevelTools,
SkillPalette: a.SkillPalette,
SubAgentPalette: a.SubAgentPalette,
Critic: run.CriticConfig{
Enabled: a.CriticEnabled,
BackstopMultiplier: a.CriticBackstopMultiplier,
},
}
for _, p := range a.Phases {
ra.Phases = append(ra.Phases, run.Phase{
Name: p.Name,
SystemPrompt: p.SystemPrompt,
ModelTier: p.ModelTier,
MaxIterations: p.MaxIter,
Tools: p.Tools,
Optional: p.Optional,
})
}
return ra
}
+115
View File
@@ -0,0 +1,115 @@
package persona
import (
"context"
"errors"
"time"
)
// ErrNotFound is returned when an agent lookup fails. Callers compare
// with errors.Is(err, ErrNotFound).
var ErrNotFound = errors.New("agent not found")
// Storage is the persistence interface for the agents system.
//
// Why: tests substitute fake implementations; production wires
// through pkg/logic/storage's Grand Storage which embeds this
// interface. Mirrors the three-layer pattern in
// pkg/logic/storage/CLAUDE.md (domain → GORM → DB).
//
// What: Phase 1 CRUD plus Phase 3 trigger queries
// (ListDueScheduled, GetAgentByWebhookSecret,
// ListAgentsByChatbotChannelFilter, MarkScheduledRun). Trigger
// queries are read by the agentsched runner, webhook router, and
// chatbot tool provider; all are gated behind the
// agents.triggers.enabled convar so old skill-driven paths keep
// running until the convar flips.
//
// Test: see storage_round_trip_test.go for round-trip coverage.
type Storage interface {
// (Mort's Discord command-binding CRUD — the CommandBindingStorage
// embedding — stays a host concern and is NOT part of the executus
// persona Storage seam.)
// InitializeAgentStorage prepares storage (e.g. AutoMigrate)
// and is idempotent. Called from the grand storage's
// InitializeAll path.
InitializeAgentStorage(ctx context.Context) error
// SaveAgent creates or updates an Agent by ID. ID must be
// non-empty (Phase 1 admin commands mint a UUID).
SaveAgent(ctx context.Context, a *Agent) error
// GetAgent returns the agent with the given ID, or ErrNotFound.
GetAgent(ctx context.Context, id string) (*Agent, error)
// GetAgentByName resolves (owner_id, name) → agent. ownerID
// must match exactly (Phase 1 has no shared/public visibility
// yet; every agent is owned).
GetAgentByName(ctx context.Context, ownerID, name string) (*Agent, error)
// ListAgents returns every agent owned by the given member ID,
// sorted by Name ASC.
ListAgents(ctx context.Context, ownerID string) ([]*Agent, error)
// ListAllAgents returns every agent across all owners, sorted by
// (OwnerID ASC, Name ASC) so builtin rows (OwnerID="builtin")
// group together, then numeric Discord-ID owners in lexical order,
// then chatbot-shadow rows whose OwnerID is the chatbot owner's
// Discord ID but whose Name carries the "chatbot:" prefix.
//
// Why: Phase 1 admin commands ran owner-scoped (a steve-owned
// agent list shows ONLY steve's rows), which hid builtin and
// shadow Agents from the admin view. `.agent list` for admins now
// uses this method to surface every row. Non-admin invocations
// (or `.agent list --mine`) keep using ListAgents.
//
// Storage MAY back this with a single full-table scan — admin
// row counts are small (dozens to low hundreds), so no need for
// pagination at this phase.
ListAllAgents(ctx context.Context) ([]*Agent, error)
// DeleteAgent removes an agent by ID. Idempotent — deleting a
// missing row returns nil.
DeleteAgent(ctx context.Context, id string) error
// GetAgentByWebhookSecret resolves a posted /webhooks/<secret> URL
// to the matching agent. Returns ErrNotFound when no agent has
// the secret. Phase 3 webhook router consults this AFTER the
// existing Skill lookup falls through, but only when
// agents.triggers.enabled is true.
//
// Empty secret is rejected with ErrNotFound (empty WebhookSecret
// rows are NOT webhook-enabled — the application layer guards
// this, the lookup defends against accidental match).
GetAgentByWebhookSecret(ctx context.Context, secret string) (*Agent, error)
// ListAgentsByChatbotChannelFilter returns every agent with a
// non-empty ChatbotChannelFilter. Phase 3 chatbot tool provider
// uses this on every chatbot turn to assemble the per-channel
// tool list (gated by agents.triggers.enabled). The result is
// not channel-filtered here — the provider applies the channel
// filter predicate (registered in skills.ChannelFilterRegistry)
// to each row.
//
// Why no channel filter at the storage layer: the filter is a
// runtime predicate (e.g. dm_only depends on the live Discord
// channel kind cache), not a static column we can index on.
ListAgentsByChatbotChannelFilter(ctx context.Context) ([]*Agent, error)
// ListScheduledAgents returns every agent with a non-empty
// Schedule whose NextRunAt is at or before `dueBefore`. Result
// is ordered by NextRunAt ASC so the scheduler runner can drain
// in oldest-due-first order. Mirrors skills.Storage.ListDueScheduled.
//
// Phase 3 scheduler reads this on every tick when
// agents.triggers.enabled is true. The (Schedule, NextRunAt)
// composite index backs the query — see gorm tags on gormAgent.
ListScheduledAgents(ctx context.Context, dueBefore time.Time) ([]*Agent, error)
// MarkAgentScheduledRun atomically updates LastScheduledRunAt
// and NextRunAt for the given agent. Called by the agentsched
// runner after each scheduled invocation. Mirrors
// skills.Storage.MarkScheduledRun semantics.
MarkAgentScheduledRun(ctx context.Context, agentID string, ranAt, nextAt time.Time) error
}