Files
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

121 lines
2.9 KiB
Go

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
}