Files
executus/persona/storage.go
T
steve f521c583bd
executus CI / test (pull_request) Failing after 58s
Adversarial Review (Gadfly) / review (pull_request) Successful in 20m1s
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-26 22:24:18 -04:00

116 lines
5.0 KiB
Go

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
}