Files
executus/persona/agent.go
steve 2260480c81 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>
2026-06-27 00:12:19 -04:00

192 lines
8.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}