P4: persona noun — Agent + ToRunnable bridge + Memory store
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user