d82cef46b4
executus CI / test (push) Failing after 1m4s
Security (all 3 models — HIGH): audit OnTool persisted raw tool args + results verbatim for the very tools the OnStep narration-redaction flags as secret (mcp_call/email_send/http_*) — the args/results are what CARRY the secret, so they landed in skill_run_logs unredacted. Factored the predicate into isSecretTool() (single source of truth) and OnTool now emits args_redacted/result_redacted (+ lengths) for secret tools. Test asserts no secret reaches the log. (persona) webhook_ip_allowlist entries are now CIDR/IP-validated at load (malformed dropped + warned) instead of accepted raw. Contract correctness (glm-5.2 + deepseek) — audit Memory now honors its documented Storage contract: ListChildrenByParent/ListFinishedRunsBefore return oldest-first; WalkParentChain returns root-first and honors MaxParentChainDepth; ListRunsFiltered clamps limit (<=0 or >500 -> 50); ListFinishedRunsBefore with limit<=0 returns none; an explicit RunFilter.Status (incl. "dry_run") matches regardless of IncludeDryRun; LastRunBySkills counts only status=="ok" unless includeFailed. (PurgeOlderThan's FinishedAt key is the SAFE behavior — in-flight runs retained — so the doc was aligned to it, not the impl.) Error-handling: appendLog now uses a bounded context (auditAppendTimeout=3s) so a hung backend can't block the run goroutine on the hot path; Sink.StartRun logs its (still best-effort) failure instead of swallowing it; budget Memory.Get uses RLock (RWMutex); budget package doc fixed (was skillexec's); Check uses the budgetWindow constant, not a duplicated literal. Triaged false-positive: NewNoOpBudget returning BudgetTracker is assignable to run.Budget (identical method sets) — no change needed. Core go.sum still free of host/DB deps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
600 lines
23 KiB
Go
600 lines
23 KiB
Go
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"
|
||
"net"
|
||
"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,
|
||
})
|
||
}
|
||
|
||
// Validate the webhook IP allow-list (CIDR or bare IP); drop + warn on
|
||
// malformed entries so a typo can't silently widen or void the allow-list.
|
||
allowlist := validateIPAllowlist(m.WebhookIPAllowlist, m.Name)
|
||
|
||
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: allowlist,
|
||
ChatbotChannelFilter: strings.TrimSpace(m.ChatbotChannelFilter),
|
||
DefaultEmoji: m.DefaultEmoji,
|
||
StateReactEmoji: m.StateReact,
|
||
Tags: m.Tags,
|
||
Phases: phases,
|
||
}
|
||
return ag, nil
|
||
}
|
||
|
||
// validateIPAllowlist keeps only entries that parse as a CIDR block or a bare
|
||
// IP; malformed entries are dropped with a warning (a typo must not silently
|
||
// widen or void the webhook allow-list). The struct field documents "CIDR
|
||
// strings", so this enforces it at load time.
|
||
func validateIPAllowlist(entries []string, agent string) []string {
|
||
var out []string
|
||
for _, e := range entries {
|
||
e = strings.TrimSpace(e)
|
||
if e == "" {
|
||
continue
|
||
}
|
||
if _, _, err := net.ParseCIDR(e); err == nil {
|
||
out = append(out, e)
|
||
continue
|
||
}
|
||
if ip := net.ParseIP(e); ip != nil {
|
||
out = append(out, e)
|
||
continue
|
||
}
|
||
slog.Warn("agents: dropping malformed webhook_ip_allowlist entry (not a CIDR or IP)", "agent", agent, "entry", e)
|
||
}
|
||
return out
|
||
}
|