Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b194a9621d | |||
| 954efde474 | |||
| cb16008b14 | |||
| 95f564ac4e | |||
| 41659b2412 | |||
| f521c583bd | |||
| 3f14aae032 | |||
| addf3a19d1 |
@@ -103,3 +103,15 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: core go.sum is free of host/DB dependencies."
|
||||
|
||||
- name: contrib/store (nested SQLite module — isolated from core)
|
||||
run: |
|
||||
# contrib/store is a SEPARATE module carrying modernc.org/sqlite; the
|
||||
# core's `go test ./...` doesn't reach it. Build + test it on its own,
|
||||
# and confirm it DOES carry the driver the core forbids (proof the
|
||||
# split works: persistence lives here, not in the core go.sum).
|
||||
cd contrib/store
|
||||
go build ./...
|
||||
go test -race -count=1 -timeout 5m ./...
|
||||
grep -qE 'modernc.org/sqlite' go.sum || { echo "ERROR: contrib/store should carry the sqlite driver"; exit 1; }
|
||||
echo "OK: contrib/store builds, tests pass, and owns the SQLite dep."
|
||||
|
||||
@@ -61,16 +61,26 @@ CORE (majordomo + stdlib):
|
||||
tools/{web,net,store,compose,meta,comms} generic tools [P3]
|
||||
|
||||
BATTERIES (opt-in siblings, each nil-safe + a default):
|
||||
persona/ Agent noun + AgentStore seam + yml loader [P4]
|
||||
skill/ rich Skill + SkillStore seam + toml loader [P4]
|
||||
audit/ run-trace Sink (+ Noop/Slog) [P4]
|
||||
persona/ Agent noun + Storage seam + builtin loader [P4 ~]
|
||||
+ ToRunnable() bridge to run.RunnableAgent +
|
||||
Memory default (host: chatbot/commands/personalization)
|
||||
skill/ Skill noun + LEAN SkillStore (lifecycle/ [P4 ~]
|
||||
versions/schedule — NOT mort's 60-method
|
||||
monster) + ToRunnable + Memory default
|
||||
audit/ run.Audit Sink + Writer + queryable Memory [P4 ✓]
|
||||
default (skillaudit Storage iface; GORM stays in mort)
|
||||
critic/ two-tier timeout state machine + Escalator [P4]
|
||||
schedule/ cron runner cores [P4]
|
||||
checkpoint/ durable resume seam [P4]
|
||||
budget/ rolling-window tracker (+ NoOp) [P4]
|
||||
budget/ DBBudget rolling-7d + NoOp (run.Budget); [P4 ✓]
|
||||
BudgetStorage iface + Memory default
|
||||
|
||||
contrib/store/ SECOND module (+ modernc.org/sqlite): [P4]
|
||||
in-memory + pure-Go SQLite impls of every *Store seam
|
||||
contrib/store/ SECOND module (+ modernc.org/sqlite): [P4 ✓]
|
||||
pure-Go SQLite impls of ALL store seams: budget +
|
||||
persona + skill + audit ✓ (JSON-blob+indexed cols,
|
||||
round-trip tested). CI proves the driver lands HERE,
|
||||
not in the core go.sum.
|
||||
CI proves the driver lands HERE, not in the core go.sum.
|
||||
```
|
||||
|
||||
### The one architectural move
|
||||
|
||||
@@ -61,7 +61,7 @@ Two tiers in one module (`go.mod` = majordomo + stdlib only):
|
||||
ships a default, so you add only what you use.
|
||||
|
||||
Persistence that needs a real database lives in a **separate** nested module
|
||||
(`contrib/store`, pure-Go SQLite) so the core never drags in a DB driver — a
|
||||
(`contrib/store`, pure-Go SQLite — the `budget` store landed first, conformance-tested) so the core never drags in a DB driver — a
|
||||
static-binary host (gadfly) stays static.
|
||||
|
||||
## License
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/audit"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/tool"
|
||||
)
|
||||
|
||||
// TestAuditBatteryEndToEnd wires the audit battery (Memory storage) into
|
||||
// run.Ports.Audit, runs an agent, and verifies the run was recorded and is
|
||||
// queryable — proving Sink/Writer/Memory satisfy the core seams end to end.
|
||||
func TestAuditBatteryEndToEnd(t *testing.T) {
|
||||
mem := audit.NewMemory()
|
||||
|
||||
fp := fake.New("fake")
|
||||
fp.Enqueue("m", fake.Reply("the answer"))
|
||||
m, err := fp.Model("m")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ex := run.New(run.Config{
|
||||
Registry: tool.NewRegistry(),
|
||||
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) {
|
||||
return ctx, m, nil
|
||||
},
|
||||
Ports: run.Ports{Audit: audit.NewSink(mem)},
|
||||
})
|
||||
|
||||
res := ex.Run(context.Background(),
|
||||
run.RunnableAgent{ID: "agent-1", Name: "a", ModelTier: "m"},
|
||||
tool.Invocation{RunID: "run-xyz", CallerID: "caller-1"},
|
||||
"question")
|
||||
if res.Err != nil {
|
||||
t.Fatalf("run error: %v", res.Err)
|
||||
}
|
||||
|
||||
// The run was recorded with a terminal status + output.
|
||||
got, err := mem.GetRun(context.Background(), "run-xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRun: %v", err)
|
||||
}
|
||||
if got.Status != "ok" {
|
||||
t.Errorf("status = %q, want ok", got.Status)
|
||||
}
|
||||
if got.Output != "the answer" {
|
||||
t.Errorf("output = %q, want %q", got.Output, "the answer")
|
||||
}
|
||||
if got.FinishedAt == nil {
|
||||
t.Error("FinishedAt should be set after the run")
|
||||
}
|
||||
if got.SkillID != "agent-1" {
|
||||
t.Errorf("SkillID = %q, want agent-1 (the subject id)", got.SkillID)
|
||||
}
|
||||
|
||||
// And it is queryable by caller.
|
||||
runs, err := mem.ListRunsByCaller(context.Background(), "caller-1", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRunsByCaller: %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].ID != "run-xyz" {
|
||||
t.Errorf("ListRunsByCaller = %+v, want [run-xyz]", runs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNilSinkRecordsNothing: NewSink(nil) is equivalent to no audit.
|
||||
func TestNilSinkRecordsNothing(t *testing.T) {
|
||||
s := audit.NewSink(nil)
|
||||
if rec := s.StartRun(context.Background(), run.RunInfo{RunID: "r"}); rec != nil {
|
||||
t.Error("NewSink(nil).StartRun should return a nil recorder")
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Memory is an in-process Storage: it retains runs + logs in memory so a light
|
||||
// host (or a test) gets queryable run history with zero setup. It is bounded
|
||||
// only by process memory — a host that runs forever should PurgeOlderThan
|
||||
// periodically, or use a persistent Storage. Construct with NewMemory.
|
||||
//
|
||||
// Mort uses its GORM/MySQL Storage; contrib/store adds a durable SQLite one.
|
||||
// Memory is the zero-dependency default behind audit.NewSink(audit.NewMemory()).
|
||||
type Memory struct {
|
||||
mu sync.RWMutex
|
||||
order []string // run ids in insertion order
|
||||
runs map[string]SkillRun // by run id
|
||||
logs map[string][]SkillRunLog // by run id
|
||||
}
|
||||
|
||||
// NewMemory returns an empty in-memory Storage.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{runs: map[string]SkillRun{}, logs: map[string][]SkillRunLog{}}
|
||||
}
|
||||
|
||||
var _ Storage = (*Memory)(nil)
|
||||
|
||||
func (m *Memory) Initialize(context.Context) error { return nil }
|
||||
|
||||
func (m *Memory) StartRun(_ context.Context, run SkillRun) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.runs[run.ID]; !ok {
|
||||
m.order = append(m.order, run.ID)
|
||||
}
|
||||
m.runs[run.ID] = run
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) FinishRun(_ context.Context, runID string, s RunStats) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
r, ok := m.runs[runID]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
r.FinishedAt = &now
|
||||
r.Status = s.Status
|
||||
r.Output = s.Output
|
||||
r.Error = s.Error
|
||||
r.ToolCallsCount = s.ToolCalls
|
||||
r.RuntimeSeconds = s.RuntimeSeconds
|
||||
r.TotalInputTokens = s.InputTokens
|
||||
r.TotalOutputTokens = s.OutputTokens
|
||||
r.TotalThinkingTokens = s.ThinkingTokens
|
||||
m.runs[runID] = r
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) AppendLog(_ context.Context, log SkillRunLog) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.logs[log.RunID] = append(m.logs[log.RunID], log)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetRun(_ context.Context, runID string) (*SkillRun, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
r, ok := m.runs[runID]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListLogsByRun(_ context.Context, runID string) ([]SkillRunLog, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
ls := append([]SkillRunLog(nil), m.logs[runID]...)
|
||||
sort.SliceStable(ls, func(i, j int) bool { return ls[i].Sequence < ls[j].Sequence })
|
||||
return ls, nil
|
||||
}
|
||||
|
||||
// newestFirst returns the retained runs in reverse insertion order, optionally
|
||||
// filtered. Caller holds at least RLock.
|
||||
func (m *Memory) newestFirst(keep func(SkillRun) bool) []SkillRun {
|
||||
out := make([]SkillRun, 0, len(m.order))
|
||||
for i := len(m.order) - 1; i >= 0; i-- {
|
||||
r := m.runs[m.order[i]]
|
||||
if keep == nil || keep(r) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func page(rs []SkillRun, offset, limit int) []SkillRun {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(rs) {
|
||||
return nil
|
||||
}
|
||||
rs = rs[offset:]
|
||||
if limit > 0 && limit < len(rs) {
|
||||
rs = rs[:limit]
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
func (m *Memory) ListRunsBySkill(ctx context.Context, skillID string, limit int) ([]SkillRun, error) {
|
||||
return m.ListRunsBySkillPaginated(ctx, skillID, 0, limit, false)
|
||||
}
|
||||
|
||||
func (m *Memory) ListRunsBySkillPaginated(_ context.Context, skillID string, offset, limit int, includeDryRun bool) ([]SkillRun, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return page(m.newestFirst(func(r SkillRun) bool {
|
||||
return r.SkillID == skillID && (includeDryRun || r.Status != "dry_run")
|
||||
}), offset, limit), nil
|
||||
}
|
||||
|
||||
func (m *Memory) CountRunsBySkill(_ context.Context, skillID string, includeDryRun bool) (int64, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return int64(len(m.newestFirst(func(r SkillRun) bool {
|
||||
return r.SkillID == skillID && (includeDryRun || r.Status != "dry_run")
|
||||
}))), nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListRunsByCaller(_ context.Context, callerID string, limit int) ([]SkillRun, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return page(m.newestFirst(func(r SkillRun) bool {
|
||||
return r.CallerID == callerID && r.Status != "dry_run"
|
||||
}), 0, limit), nil
|
||||
}
|
||||
|
||||
func (m *Memory) matchesFilter(r SkillRun, f RunFilter) bool {
|
||||
if !f.IncludeDryRun && r.Status == "dry_run" {
|
||||
return false
|
||||
}
|
||||
if f.Status != "" && r.Status != f.Status {
|
||||
return false
|
||||
}
|
||||
if f.SkillID != "" && r.SkillID != f.SkillID {
|
||||
return false
|
||||
}
|
||||
if f.CallerID != "" && r.CallerID != f.CallerID {
|
||||
return false
|
||||
}
|
||||
if f.ChannelID != "" && r.ChannelID != f.ChannelID {
|
||||
return false
|
||||
}
|
||||
if f.TopLevelOnly && r.ParentRunID != "" {
|
||||
return false
|
||||
}
|
||||
if !f.Since.IsZero() && r.StartedAt.Before(f.Since) {
|
||||
return false
|
||||
}
|
||||
if !f.Until.IsZero() && r.StartedAt.After(f.Until) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *Memory) ListRunsFiltered(_ context.Context, f RunFilter, offset, limit int) ([]SkillRun, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return page(m.newestFirst(func(r SkillRun) bool { return m.matchesFilter(r, f) }), offset, limit), nil
|
||||
}
|
||||
|
||||
func (m *Memory) CountRunsFiltered(_ context.Context, f RunFilter) (int64, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return int64(len(m.newestFirst(func(r SkillRun) bool { return m.matchesFilter(r, f) }))), nil
|
||||
}
|
||||
|
||||
func (m *Memory) PurgeOlderThan(_ context.Context, t time.Time) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var purged int64
|
||||
kept := m.order[:0:0]
|
||||
for _, id := range m.order {
|
||||
r := m.runs[id]
|
||||
if r.FinishedAt != nil && r.FinishedAt.Before(t) {
|
||||
delete(m.runs, id)
|
||||
delete(m.logs, id)
|
||||
purged++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, id)
|
||||
}
|
||||
m.order = kept
|
||||
return purged, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListChildrenByParent(_ context.Context, parentRunID string) ([]SkillRun, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.newestFirst(func(r SkillRun) bool { return r.ParentRunID == parentRunID }), nil
|
||||
}
|
||||
|
||||
func (m *Memory) WalkParentChain(_ context.Context, runID string) ([]SkillRun, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var chain []SkillRun
|
||||
seen := map[string]bool{}
|
||||
for id := runID; id != ""; {
|
||||
r, ok := m.runs[id]
|
||||
if !ok || seen[id] {
|
||||
break
|
||||
}
|
||||
seen[id] = true
|
||||
chain = append(chain, r)
|
||||
id = r.ParentRunID
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListFinishedRunsBefore(_ context.Context, cutoff time.Time, limit int) ([]SkillRun, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return page(m.newestFirst(func(r SkillRun) bool {
|
||||
return r.FinishedAt != nil && r.FinishedAt.Before(cutoff)
|
||||
}), 0, limit), nil
|
||||
}
|
||||
|
||||
func (m *Memory) LastRunBySkills(_ context.Context, skillIDs []string, includeFailed bool) (map[string]time.Time, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
want := map[string]bool{}
|
||||
for _, id := range skillIDs {
|
||||
want[id] = true
|
||||
}
|
||||
out := map[string]time.Time{}
|
||||
for _, id := range m.order {
|
||||
r := m.runs[id]
|
||||
if !want[r.SkillID] {
|
||||
continue
|
||||
}
|
||||
if !includeFailed && (r.Status == "error" || r.Status == "timeout") {
|
||||
continue
|
||||
}
|
||||
if r.StartedAt.After(out[r.SkillID]) {
|
||||
out[r.SkillID] = r.StartedAt
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
)
|
||||
|
||||
// Sink adapts an audit Storage to the run.Audit port: StartRun opens a run row
|
||||
// and returns a per-run recorder (a Writer) that the executor feeds with steps,
|
||||
// tool calls, and the terminal roll-up. This is what plugs the audit battery
|
||||
// into run.Ports.Audit — mort backs it with its GORM Storage, a light host with
|
||||
// Memory() (or omits it entirely).
|
||||
type Sink struct{ storage Storage }
|
||||
|
||||
// NewSink wraps a Storage as a run.Audit. A nil Storage yields a Sink whose
|
||||
// StartRun returns nil (the executor then records nothing) — so NewSink(nil) is
|
||||
// equivalent to leaving run.Ports.Audit unset.
|
||||
func NewSink(storage Storage) *Sink { return &Sink{storage: storage} }
|
||||
|
||||
// compile-time proof the adapter satisfies the core seams.
|
||||
var (
|
||||
_ run.Audit = (*Sink)(nil)
|
||||
_ run.RunRecorder = (*recorder)(nil)
|
||||
)
|
||||
|
||||
// StartRun records the run start and returns a recorder. Implements run.Audit.
|
||||
func (s *Sink) StartRun(ctx context.Context, info run.RunInfo) run.RunRecorder {
|
||||
if s == nil || s.storage == nil {
|
||||
return nil
|
||||
}
|
||||
started := info.StartedAt
|
||||
if started.IsZero() {
|
||||
started = time.Now()
|
||||
}
|
||||
// Best-effort: a failed StartRun must not break the user-visible run.
|
||||
_ = s.storage.StartRun(ctx, SkillRun{
|
||||
ID: info.RunID,
|
||||
SkillID: info.SubjectID,
|
||||
CallerID: info.CallerID,
|
||||
ChannelID: info.ChannelID,
|
||||
ParentRunID: info.ParentRunID,
|
||||
Inputs: info.Inputs,
|
||||
StartedAt: started,
|
||||
Status: "running",
|
||||
})
|
||||
return &recorder{w: NewWriter(s.storage, info.RunID)}
|
||||
}
|
||||
|
||||
// recorder adapts a *Writer to run.RunRecorder, converting run.RunStats to the
|
||||
// audit RunStats on Close (the two have identical fields).
|
||||
type recorder struct{ w *Writer }
|
||||
|
||||
func (r *recorder) TokenStats() (in, out, thinking int64) { return r.w.TokenStats() }
|
||||
func (r *recorder) ToolCallsCount() int { return r.w.ToolCallsCount() }
|
||||
func (r *recorder) OnStep(iter int, resp *llm.Response) { r.w.OnStep(iter, resp) }
|
||||
func (r *recorder) OnTool(call llm.ToolCall, result string) { r.w.OnTool(call, result) }
|
||||
func (r *recorder) LogEvent(eventType string, payload map[string]any) {
|
||||
r.w.LogEvent(eventType, payload)
|
||||
}
|
||||
func (r *recorder) LogError(msg string) { r.w.LogError(msg) }
|
||||
func (r *recorder) Close(ctx context.Context, s run.RunStats) {
|
||||
r.w.Close(ctx, RunStats{
|
||||
Status: s.Status,
|
||||
Output: s.Output,
|
||||
Error: s.Error,
|
||||
ToolCalls: s.ToolCalls,
|
||||
RuntimeSeconds: s.RuntimeSeconds,
|
||||
InputTokens: s.InputTokens,
|
||||
OutputTokens: s.OutputTokens,
|
||||
ThinkingTokens: s.ThinkingTokens,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Package skillaudit persists skill execution traces: per-run summary rows
|
||||
// (skill_runs) and per-step event logs (skill_run_logs). The executor in
|
||||
// pkg/logic/skillexec emits events through a Writer; the storage layer is
|
||||
// kept separate so tests can mock it and so retention pruning has a clear
|
||||
// home.
|
||||
//
|
||||
// Why: agentic runs can be long, multi-tool affairs. Without a structured
|
||||
// audit trail, debugging "why did the LLM do that?" is impossible. The
|
||||
// log table is keyed by (run_id, sequence) so insert order is preserved.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a run lookup fails.
|
||||
var ErrNotFound = errors.New("skill run not found")
|
||||
|
||||
// SkillRun is the per-invocation summary row. One per call to
|
||||
// Executor.Run. Status transitions through running → ok / error /
|
||||
// timeout / budget_exceeded / dry_run.
|
||||
type SkillRun struct {
|
||||
ID string
|
||||
SkillID string
|
||||
CallerID string
|
||||
ChannelID string
|
||||
Inputs map[string]any
|
||||
StartedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
Status string // running|ok|error|timeout|budget_exceeded|dry_run
|
||||
Output string
|
||||
Error string
|
||||
ToolCallsCount int
|
||||
RuntimeSeconds float64
|
||||
// ParentRunID is the run_id of the parent skill that invoked this
|
||||
// run via skill_invoke. Empty for top-level invocations. Indexed
|
||||
// in the gorm model so call-tree queries (ListChildrenByParent +
|
||||
// WalkParentChain) are cheap.
|
||||
ParentRunID string
|
||||
|
||||
// Token roll-ups, summed across all model completions in this run
|
||||
// (one Usage per OnStep). All default to 0 when the provider did
|
||||
// not expose token usage.
|
||||
TotalInputTokens int64
|
||||
TotalOutputTokens int64
|
||||
TotalThinkingTokens int64
|
||||
}
|
||||
|
||||
// RunStats captures the terminal state of a run for FinishRun. Bundling
|
||||
// these into one struct (vs a long positional argument list) keeps
|
||||
// callers readable; future fields slot in here without touching every
|
||||
// call site.
|
||||
//
|
||||
// Why: FinishRun originally took six positional args; adding token
|
||||
// columns would push it higher. A struct is the idiomatic Go way to
|
||||
// avoid the positional-arg explosion.
|
||||
type RunStats struct {
|
||||
Status string // ok|error|timeout|budget_exceeded|dry_run
|
||||
Output string // final agent output (empty on error)
|
||||
Error string // error message (empty on success)
|
||||
ToolCalls int // total OnTool count
|
||||
RuntimeSeconds float64 // wall-clock duration
|
||||
|
||||
// Token roll-ups (all default to 0 when token usage was not
|
||||
// exposed by the provider).
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
ThinkingTokens int64
|
||||
}
|
||||
|
||||
// SkillRunLog is one event recorded during a run. EventType ∈
|
||||
// step|tool_call|tool_result|error. Payload is opaque JSON the writer
|
||||
// emits.
|
||||
type SkillRunLog struct {
|
||||
RunID string
|
||||
Sequence int
|
||||
EventType string
|
||||
Payload map[string]any
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// RunFilter is the predicate bundle for the cross-surface "recent runs"
|
||||
// query (ListRunsFiltered / CountRunsFiltered). Every field is optional;
|
||||
// the zero value matches the most recent runs across ALL audited surfaces
|
||||
// (agents + skills). This powers the admin agent-trace debug view and the
|
||||
// Claude debug API's /runs list.
|
||||
//
|
||||
// Why a struct (vs positional args): the debug list filters along several
|
||||
// independent axes and more will be added; bundling avoids a positional
|
||||
// explosion and keeps call sites readable.
|
||||
type RunFilter struct {
|
||||
Status string // exact status match; "" = all (dry_run excluded unless IncludeDryRun)
|
||||
SkillID string // exact skill_id (holds the agent UUID for agent runs)
|
||||
CallerID string // exact caller (Discord member id)
|
||||
ChannelID string // exact channel id
|
||||
|
||||
// TopLevelOnly restricts to root runs (parent_run_id = ''), hiding
|
||||
// nested sub-agent / sub-skill runs from the firehose. The debug list
|
||||
// defaults this on; an "include nested" toggle clears it.
|
||||
TopLevelOnly bool
|
||||
|
||||
// IncludeDryRun surfaces status="dry_run" sandbox rows, which are
|
||||
// excluded by default. Ignored when Status is set explicitly (an
|
||||
// explicit Status=="dry_run" still matches).
|
||||
IncludeDryRun bool
|
||||
|
||||
// Since / Until bound started_at: started_at >= Since (zero = no lower
|
||||
// bound) and started_at < Until (zero = no upper bound).
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
}
|
||||
|
||||
// Storage is the persistence interface for skill runs and per-step logs.
|
||||
//
|
||||
// Why: tests substitute fake implementations; production wires
|
||||
// NewGormStorage. Keep the interface narrow — the system only needs CRUD
|
||||
// plus the retention prune helper.
|
||||
type Storage interface {
|
||||
Initialize(ctx context.Context) error
|
||||
|
||||
// StartRun inserts the run with status=running. The caller MUST
|
||||
// invoke FinishRun later (or the row stays in running indefinitely
|
||||
// — operationally that signals a crash mid-run, which is useful
|
||||
// signal).
|
||||
StartRun(ctx context.Context, run SkillRun) error
|
||||
|
||||
// FinishRun updates the running row with terminal status, output
|
||||
// and stats. Idempotent on second call (last write wins).
|
||||
//
|
||||
// V5: takes a RunStats struct so token + cost columns can be
|
||||
// written alongside the legacy fields without changing the
|
||||
// signature for every future addition.
|
||||
FinishRun(ctx context.Context, runID string, stats RunStats) error
|
||||
|
||||
// AppendLog adds one event to the run's log. Sequence numbers must
|
||||
// be unique per run; the writer is responsible for monotonic
|
||||
// ordering.
|
||||
AppendLog(ctx context.Context, log SkillRunLog) error
|
||||
|
||||
// GetRun returns the run summary, or ErrNotFound.
|
||||
GetRun(ctx context.Context, runID string) (*SkillRun, error)
|
||||
|
||||
// ListLogsByRun returns all logs for a run in sequence order.
|
||||
ListLogsByRun(ctx context.Context, runID string) ([]SkillRunLog, error)
|
||||
|
||||
// ListRunsBySkill returns recent runs for a skill, newest first,
|
||||
// capped at limit. Excludes dry-run rows by default — use
|
||||
// ListRunsBySkillPaginated with includeDryRun=true to see them.
|
||||
ListRunsBySkill(ctx context.Context, skillID string, limit int) ([]SkillRun, error)
|
||||
|
||||
// ListRunsBySkillPaginated returns recent runs for a skill, newest
|
||||
// first, with offset+limit. When includeDryRun is false, rows with
|
||||
// status="dry_run" are excluded (matches the wizard's sandbox
|
||||
// status; see skillaudit.Writer / wizardtools docs).
|
||||
//
|
||||
// Why a separate paginated method vs. expanding ListRunsBySkill:
|
||||
// callers that need the legacy "last N" view (Discord .skill runs,
|
||||
// chatbot tool result) want the simpler signature; the paginated
|
||||
// view is webui-specific.
|
||||
ListRunsBySkillPaginated(ctx context.Context, skillID string,
|
||||
offset, limit int, includeDryRun bool) ([]SkillRun, error)
|
||||
|
||||
// CountRunsBySkill returns the total number of runs for a skill.
|
||||
// When includeDryRun is false, dry-run rows are excluded so the
|
||||
// count matches the default ListRunsBySkillPaginated result.
|
||||
CountRunsBySkill(ctx context.Context, skillID string, includeDryRun bool) (int64, error)
|
||||
|
||||
// ListRunsByCaller returns recent runs by a caller, newest first,
|
||||
// capped at limit.
|
||||
ListRunsByCaller(ctx context.Context, callerID string, limit int) ([]SkillRun, error)
|
||||
|
||||
// ListRunsFiltered returns runs matching f, newest first
|
||||
// (started_at DESC), with offset+limit. With an all-zero filter it
|
||||
// returns the most recent runs across EVERY audited surface (agents +
|
||||
// skills) — the cross-surface feed behind the admin agent-trace debug
|
||||
// view and the Claude debug API. dry_run rows are excluded unless
|
||||
// f.IncludeDryRun or f.Status=="dry_run". limit is clamped (<=0 or
|
||||
// >500 → 50) to bound admin scans.
|
||||
ListRunsFiltered(ctx context.Context, f RunFilter, offset, limit int) ([]SkillRun, error)
|
||||
|
||||
// CountRunsFiltered returns the total rows matching f (ignoring
|
||||
// offset/limit), for pagination math.
|
||||
CountRunsFiltered(ctx context.Context, f RunFilter) (int64, error)
|
||||
|
||||
// PurgeOlderThan deletes runs (and their logs) whose StartedAt is
|
||||
// strictly before t. Returns the number of runs deleted.
|
||||
PurgeOlderThan(ctx context.Context, t time.Time) (int64, error)
|
||||
|
||||
// ListChildrenByParent returns all SkillRun rows where
|
||||
// parent_run_id == parentRunID, oldest first. Used for the
|
||||
// call-tree view (skill_invoke trace section) and as a building
|
||||
// block for WalkParentChain.
|
||||
//
|
||||
// Returns an empty slice when parentRunID has no children. An
|
||||
// empty parentRunID never matches anything (no row stores ""
|
||||
// as a parent — that's the top-level sentinel).
|
||||
ListChildrenByParent(ctx context.Context, parentRunID string) ([]SkillRun, error)
|
||||
|
||||
// WalkParentChain walks from runID up via parent_run_id, returning
|
||||
// the chain of SkillRun summaries (oldest = root first, newest =
|
||||
// runID last). Used by the loop guard in skill_invoke.
|
||||
//
|
||||
// Cap walk depth at 32 to prevent pathological loops in the data
|
||||
// itself: if the parent_run_id chain has been corrupted (e.g. by
|
||||
// a bad migration) and forms a cycle, we want a bounded result
|
||||
// rather than an infinite loop.
|
||||
WalkParentChain(ctx context.Context, runID string) ([]SkillRun, error)
|
||||
|
||||
// ListFinishedRunsBefore returns runs whose FinishedAt is strictly
|
||||
// before cutoff, oldest first, capped at limit. limit <= 0 yields
|
||||
// no rows (the caller is expected to specify a real bound).
|
||||
//
|
||||
// Why: skills.StorageSweeper drives the run-scope storage purge from
|
||||
// this query. The sweeper picks up only finished runs so an
|
||||
// in-flight run's run-scope KV/files cannot be deleted out from
|
||||
// under it.
|
||||
//
|
||||
// Test: storage_test.go covers the include/exclude boundaries
|
||||
// (running rows excluded; finished-after-cutoff excluded; finished-
|
||||
// before-cutoff included).
|
||||
ListFinishedRunsBefore(ctx context.Context, cutoff time.Time, limit int) ([]SkillRun, error)
|
||||
|
||||
// LastRunBySkills returns the most recent StartedAt timestamp per
|
||||
// skill in the input ID list. Skills with no rows simply have no
|
||||
// entry in the result map (caller distinguishes "never run" from
|
||||
// "run but no timestamp" by map key presence).
|
||||
//
|
||||
// When includeFailed is true, all non-dry-run statuses count
|
||||
// (ok / error / timeout / budget_exceeded / preempted / lane_busy).
|
||||
// When false, only status="ok" rows count — useful for "last
|
||||
// successful run" semantics on dashboards where errored runs
|
||||
// shouldn't surface as recent activity.
|
||||
//
|
||||
// Empty skillIDs short-circuits to an empty map without touching
|
||||
// the DB.
|
||||
LastRunBySkills(ctx context.Context, skillIDs []string, includeFailed bool) (map[string]time.Time, error)
|
||||
}
|
||||
|
||||
// MaxParentChainDepth is the safety cap for WalkParentChain. The loop
|
||||
// guard in skill_invoke enforces a separate (smaller) MaxInvokeDepth
|
||||
// at the tool layer; this cap exists only to bound the walk in the
|
||||
// presence of corrupted data.
|
||||
const MaxParentChainDepth = 32
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
)
|
||||
|
||||
// stepTextMax caps the per-step assistant-text preview persisted on a
|
||||
// "step" event. Large enough to capture the model's reasoning around a
|
||||
// (mis)fired tool call — the single best clue to WHY a model emitted a
|
||||
// malformed call — but bounded so the longtext payload can't balloon.
|
||||
const stepTextMax = 2000
|
||||
|
||||
// Writer wraps a Storage with the OnStep / OnTool callbacks suitable for
|
||||
// wiring into the majordomo agent loop's step observer, tracking sequence
|
||||
// numbers and tool-call counts internally.
|
||||
//
|
||||
// Why: the agent loop's observer hooks are unaware of run identity; the
|
||||
// writer captures the runID + skill metadata at construction so the
|
||||
// per-event callbacks stay simple. AppendLog failures are logged but
|
||||
// never fatal — audit must not break user-visible execution.
|
||||
//
|
||||
// What: NewWriter(storage, runID) → use OnStep / OnTool / Close. Close
|
||||
// records the final FinishRun. The executors translate each agent.Step
|
||||
// into one OnStep call (1-indexed iteration, the step's *llm.Response)
|
||||
// plus one OnTool call per executed tool.
|
||||
//
|
||||
// Test: see writer_test.go for sequence ordering and finish semantics.
|
||||
type Writer struct {
|
||||
storage Storage
|
||||
runID string
|
||||
sequence atomic.Int32
|
||||
calls atomic.Int32
|
||||
mu sync.Mutex // guards Close idempotency + token tally
|
||||
closed bool
|
||||
|
||||
// V5 token accumulator — summed across each OnStep's resp.Usage.
|
||||
// Reads come from TokenStats() so the executor can pass them to
|
||||
// FinishRun. atomics-on-Int64 would also work, but mu already
|
||||
// guards Close + we need consistent multi-field reads anyway
|
||||
// (input + output + thinking). The mutex hot-path overhead is
|
||||
// negligible vs the LLM call latency that dominates step time.
|
||||
inputTokens int64
|
||||
outputTokens int64
|
||||
thinkingTokens int64
|
||||
|
||||
// Per-step wall-clock + run-level model attribution (guarded by mu).
|
||||
// startedAt anchors the first step's duration; lastStepAt is the
|
||||
// previous step's observation time; resolvedModelLogged ensures the
|
||||
// one-shot "resolved_model" run-level event fires at most once.
|
||||
startedAt time.Time
|
||||
lastStepAt time.Time
|
||||
resolvedModelLogged bool
|
||||
}
|
||||
|
||||
// NewWriter constructs a Writer. The caller is expected to have already
|
||||
// called Storage.StartRun.
|
||||
func NewWriter(storage Storage, runID string) *Writer {
|
||||
return &Writer{storage: storage, runID: runID, startedAt: time.Now()}
|
||||
}
|
||||
|
||||
// OnStep records one agent-loop step: a "step" event with the iteration
|
||||
// number and the response's text size.
|
||||
//
|
||||
// V5: also tallies per-step token usage. majordomo populates
|
||||
// resp.Usage when the provider reports it; for providers that don't,
|
||||
// the fields stay 0 and the tally stays at 0 — the formatter then
|
||||
// renders "—" rather than a misleading "$0.00".
|
||||
//
|
||||
// Why we tally here vs in the agent loop: the loop's Result.Usage is a
|
||||
// run total; the audit row needs the same numbers, but the writer also
|
||||
// serves the live RunState accessor mid-run, so a per-step running sum
|
||||
// is the right shape. Global usage attribution is handled by the llms
|
||||
// package's instrumented models — the writer tally is strictly the
|
||||
// per-run audit roll-up.
|
||||
func (w *Writer) OnStep(iter int, resp *llm.Response) {
|
||||
if w == nil || w.storage == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
payload := map[string]any{"iter": iter}
|
||||
|
||||
w.mu.Lock()
|
||||
// Per-step wall-clock: time since the previous observed step, or since
|
||||
// run start for the first step. A long gap localises a slow/hung model
|
||||
// call — the signal that was missing when an animate step-0 call hung
|
||||
// ~5 min. NOTE: this is step-to-step wall time (model call + the prior
|
||||
// step's tool execution), not pure model latency.
|
||||
prev := w.lastStepAt
|
||||
if prev.IsZero() {
|
||||
prev = w.startedAt
|
||||
}
|
||||
if !prev.IsZero() {
|
||||
payload["step_ms"] = now.Sub(prev).Milliseconds()
|
||||
}
|
||||
w.lastStepAt = now
|
||||
if resp != nil {
|
||||
w.inputTokens += int64(resp.Usage.InputTokens)
|
||||
w.outputTokens += int64(resp.Usage.OutputTokens)
|
||||
// Thinking/reasoning tokens are a first-class Usage field in
|
||||
// majordomo (populated by the providers that report them).
|
||||
w.thinkingTokens += int64(resp.Usage.ReasoningTokens)
|
||||
}
|
||||
// One-shot run-level served-model attribution: the FIRST step with a
|
||||
// resolved model name emits a "resolved_model" event so a run that
|
||||
// errors before producing a useful step still records which model
|
||||
// served it. resp.Model is failover-aware ("provider/model-id" of the
|
||||
// element that actually served), unlike the static configured head.
|
||||
logResolvedModel := ""
|
||||
if resp != nil && resp.Model != "" && !w.resolvedModelLogged {
|
||||
w.resolvedModelLogged = true
|
||||
logResolvedModel = resp.Model
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
if resp != nil {
|
||||
payload["text_len"] = len(resp.Text())
|
||||
// Served model + why generation stopped — the two scalars that turn
|
||||
// a "model misbehaved" guess into a fact. finish_reason on an
|
||||
// empty-tool-call step disambiguates truncation (length) from a
|
||||
// deliberate empty emission (tool_calls).
|
||||
if resp.Model != "" {
|
||||
payload["model"] = resp.Model
|
||||
}
|
||||
if resp.FinishReason != "" {
|
||||
payload["finish_reason"] = string(resp.FinishReason)
|
||||
}
|
||||
// Per-step token breakdown (OnStep already reads these into the run
|
||||
// total above; persisting the per-step slice costs nothing more).
|
||||
payload["in_tokens"] = resp.Usage.InputTokens
|
||||
payload["out_tokens"] = resp.Usage.OutputTokens
|
||||
if resp.Usage.ReasoningTokens > 0 {
|
||||
payload["thinking_tokens"] = resp.Usage.ReasoningTokens
|
||||
}
|
||||
if resp.Usage.CacheReadTokens > 0 {
|
||||
payload["cache_read_tokens"] = resp.Usage.CacheReadTokens
|
||||
}
|
||||
// The model's own narration accompanying this step — the smoking gun
|
||||
// for WHY a malformed tool call was emitted. Capped; suppressed when
|
||||
// the step fired a secret-bearing tool (mcp_call/email_send/http_*)
|
||||
// whose narration could echo the secret it's about to send.
|
||||
if t := strings.TrimSpace(resp.Text()); t != "" {
|
||||
if stepHasSecretTool(resp) {
|
||||
payload["text_redacted"] = true
|
||||
} else {
|
||||
payload["text"] = truncate(t, stepTextMax)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
payload["text_len"] = 0
|
||||
}
|
||||
|
||||
w.appendLog("step", payload)
|
||||
|
||||
if logResolvedModel != "" {
|
||||
w.appendLog("resolved_model", map[string]any{"model": logResolvedModel})
|
||||
}
|
||||
}
|
||||
|
||||
// stepHasSecretTool reports whether a step's response fired a tool whose
|
||||
// surrounding narration could leak a secret (MCP args, email body/
|
||||
// recipients, raw HTTP request). Mirrors the steps.go redaction list so
|
||||
// the audit trace never persists secret-adjacent assistant text.
|
||||
func stepHasSecretTool(resp *llm.Response) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
for _, c := range resp.ToolCalls {
|
||||
switch c.Name {
|
||||
case "mcp_call", "email_send":
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(c.Name, "http_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TokenStats returns the running totals tallied from OnStep.
|
||||
// Safe to call concurrently. Returned values are a snapshot at call
|
||||
// time. Used by the executors to populate RunStats before Close
|
||||
// finalises the audit row.
|
||||
//
|
||||
// Why: the executor needs the totals AND a model name to compute cost,
|
||||
// but cost calculation is a different concern from audit persistence.
|
||||
// Exposing this getter lets the cost calculation live in the executor
|
||||
// where the model is known.
|
||||
func (w *Writer) TokenStats() (input, output, thinking int64) {
|
||||
if w == nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.inputTokens, w.outputTokens, w.thinkingTokens
|
||||
}
|
||||
|
||||
// OnTool records a "tool_call" event with the tool name and a
|
||||
// "tool_result" event with the result length. Tool count is incremented
|
||||
// for each call. The executors call this once per executed tool call
|
||||
// from their step observers (call + matching result content).
|
||||
func (w *Writer) OnTool(call llm.ToolCall, result string) {
|
||||
if w == nil || w.storage == nil {
|
||||
return
|
||||
}
|
||||
w.calls.Add(1)
|
||||
w.appendLog("tool_call", map[string]any{
|
||||
"name": call.Name,
|
||||
"args": string(call.Arguments),
|
||||
"id": call.ID,
|
||||
})
|
||||
w.appendLog("tool_result", map[string]any{
|
||||
"name": call.Name,
|
||||
"id": call.ID,
|
||||
"result": truncate(result, 4000),
|
||||
"truncated": len(result) > 4000,
|
||||
})
|
||||
}
|
||||
|
||||
// LogEvent records a custom event mid-run. The executor uses this for
|
||||
// diagnostic events (e.g. "compaction_setup" / "compaction_fired")
|
||||
// outside the canonical step / tool_call / tool_result / error set.
|
||||
// Nil-safe: no-op when receiver or storage is nil.
|
||||
//
|
||||
// Why: skill_run_logs is the only sink Steve can read from SQL, so
|
||||
// diagnostics intended for post-hoc debugging belong here. slog goes
|
||||
// to mort.log which is harder to reach from outside the host.
|
||||
func (w *Writer) LogEvent(eventType string, payload map[string]any) {
|
||||
if w == nil || w.storage == nil {
|
||||
return
|
||||
}
|
||||
w.appendLog(eventType, payload)
|
||||
}
|
||||
|
||||
// LogError records an "error" event mid-run. Distinct from the terminal
|
||||
// status set by Close.
|
||||
func (w *Writer) LogError(msg string) {
|
||||
if w == nil || w.storage == nil {
|
||||
return
|
||||
}
|
||||
w.appendLog("error", map[string]any{"message": msg})
|
||||
}
|
||||
|
||||
// Close finishes the run. The caller assembles a RunStats; the writer
|
||||
// fills in ToolCalls (which is bookkept on the writer itself) and
|
||||
// hands the full record to FinishRun.
|
||||
//
|
||||
// Idempotent: subsequent calls are no-ops.
|
||||
//
|
||||
// Why a struct vs the old positional form: v5 adds four token + cost
|
||||
// fields on top of the legacy six. The struct keeps call sites readable
|
||||
// and lets future fields slot in without churning every caller.
|
||||
//
|
||||
// Why context.WithoutCancel: the run's terminal status MUST land in
|
||||
// the audit row regardless of the run ctx's state. Pre-fix, child
|
||||
// skill runs invoked via skill_invoke / skill_invoke_parallel inherited
|
||||
// the parent agent's runCtx as their outer ctx; when the parent
|
||||
// timed out at MaxRuntime, every in-flight child's FinishRun fired
|
||||
// with that already-cancelled ctx and the row was left in
|
||||
// status=running forever. Detaching here is defence in depth — the
|
||||
// caller (skillexec.runInner / agentexec.runInner) ALSO detaches at
|
||||
// the call site, but a cancelled ctx in the writer's hands MUST NOT
|
||||
// drop the audit write. The short timeout (auditFinishTimeout) bounds
|
||||
// the write so a hung DB doesn't pin the run goroutine indefinitely.
|
||||
func (w *Writer) Close(ctx context.Context, stats RunStats) {
|
||||
if w == nil || w.storage == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
stats.ToolCalls = int(w.calls.Load())
|
||||
// Detach from the caller's deadline + cancellation. Run cleanup
|
||||
// must complete even when the run ctx is dead. The fresh
|
||||
// auditFinishTimeout caps how long we'll wait on the storage.
|
||||
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), auditFinishTimeout)
|
||||
defer cancel()
|
||||
if err := w.storage.FinishRun(finishCtx, w.runID, stats); err != nil {
|
||||
slog.Warn("skillaudit: FinishRun failed", "run_id", w.runID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// auditFinishTimeout caps how long Close will wait on the storage's
|
||||
// FinishRun call after detaching from the caller's ctx. 10s is generous
|
||||
// for a single-row UPDATE against MySQL — anything longer suggests a
|
||||
// hung connection that the run goroutine shouldn't keep waiting on.
|
||||
const auditFinishTimeout = 10 * time.Second
|
||||
|
||||
// ToolCallsCount returns how many tool invocations OnTool has seen so
|
||||
// far. Useful for budget enforcement.
|
||||
func (w *Writer) ToolCallsCount() int { return int(w.calls.Load()) }
|
||||
|
||||
func (w *Writer) appendLog(eventType string, payload map[string]any) {
|
||||
seq := int(w.sequence.Add(1))
|
||||
log := SkillRunLog{
|
||||
RunID: w.runID,
|
||||
Sequence: seq,
|
||||
EventType: eventType,
|
||||
Payload: payload,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := w.storage.AppendLog(context.Background(), log); err != nil {
|
||||
slog.Warn("skillaudit: AppendLog failed", "run_id", w.runID, "seq", seq, "type", eventType, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + fmt.Sprintf("…[+%d bytes]", len(s)-max)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Package skillexec runs saved Skill definitions via majordomo's agent
|
||||
// loop (gitea.stevedudenhoeffer.com/steve/majordomo/agent).
|
||||
//
|
||||
// Why: a Skill is data; the executor turns data into a running agent
|
||||
// (resolve model, build toolbox, start audit, run the agent loop,
|
||||
// finish audit, deliver).
|
||||
package budget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BudgetTracker enforces per-user GPU budgets in v2. v1 ships
|
||||
// NoOpBudget which always allows. The interface exists now so the v2
|
||||
// migration is a single line in the executor.
|
||||
//
|
||||
// Why interface now: the executor's Check/Commit calls would need to
|
||||
// be added in v2 anyway; doing it now means v2 only swaps NoOp for
|
||||
// DBBudget without touching call sites.
|
||||
type BudgetTracker interface {
|
||||
// Check reports whether the caller has remaining budget. Returns
|
||||
// nil for "yes" or an error describing the exhaustion.
|
||||
Check(ctx context.Context, callerID string) error
|
||||
|
||||
// Commit records that the caller spent runtimeSeconds of budget on
|
||||
// this run. Called after the agent completes (success or error).
|
||||
Commit(ctx context.Context, callerID string, runtimeSeconds float64)
|
||||
}
|
||||
|
||||
// NoOpBudget always allows and never records. v1 default.
|
||||
type NoOpBudget struct{}
|
||||
|
||||
// NewNoOpBudget constructs the no-op tracker.
|
||||
func NewNoOpBudget() BudgetTracker { return NoOpBudget{} }
|
||||
|
||||
// Check always returns nil.
|
||||
func (NoOpBudget) Check(_ context.Context, _ string) error { return nil }
|
||||
|
||||
// Commit is a no-op.
|
||||
func (NoOpBudget) Commit(_ context.Context, _ string, _ float64) {}
|
||||
|
||||
// ErrBudgetExceeded is returned by DBBudget.Check when the caller's
|
||||
// 7-day rolling window has hit the convar-configured cap.
|
||||
//
|
||||
// Why a sentinel: callers (executor, audit writer) need to distinguish
|
||||
// budget rejection from generic errors so they can record
|
||||
// status="budget_exceeded" instead of "error" and skip user-visible
|
||||
// delivery side-effects.
|
||||
var ErrBudgetExceeded = errors.New("weekly skill budget exceeded")
|
||||
|
||||
// BudgetNotifier is the optional callback DBBudget invokes when a
|
||||
// Check rejects a caller. Production wires a Discord-DM hook so the
|
||||
// user knows why their skill failed; tests inject a recorder.
|
||||
//
|
||||
// nil is allowed and is silently skipped.
|
||||
type BudgetNotifier func(ctx context.Context, userID string, secondsUsed, cap float64)
|
||||
|
||||
// DBBudget enforces per-user weekly GPU budgets via the BudgetStorage
|
||||
// interface. The "weekly" cap is a rolling 7-day window — see
|
||||
// SkillBudget for the rollover semantics.
|
||||
//
|
||||
// Why a closure for the limit instead of an int field: the cap comes
|
||||
// from a runtime convar. Reading it on every Check means a `.convar
|
||||
// set skills.user_budget_seconds_per_week 7200` takes effect on the
|
||||
// next call without restarting the bot or rewiring the executor.
|
||||
type DBBudget struct {
|
||||
storage BudgetStorage
|
||||
// weeklyLimit returns the current cap in seconds. Reads convar at
|
||||
// every Check so a runtime convar bump takes effect on the next
|
||||
// call.
|
||||
weeklyLimit func() float64
|
||||
|
||||
// notify is called when a Check rejects a caller. Optional —
|
||||
// production wires a Discord-DM hook so the user knows why their
|
||||
// skill failed. nil-safe.
|
||||
notify BudgetNotifier
|
||||
|
||||
// now is the time source. Test injects a fake clock; production
|
||||
// uses time.Now.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewDBBudget constructs a DBBudget. now may be nil — defaults to
|
||||
// time.Now.
|
||||
//
|
||||
// Why time injection: budget rollover is time-sensitive; tests need to
|
||||
// fast-forward past the 7-day boundary deterministically. now=nil
|
||||
// means production callers (mort.go) don't have to think about it.
|
||||
//
|
||||
// Test: pass a closure that returns a fixed instant; assert rollover
|
||||
// only happens when (now - WindowStart) >= 7 days.
|
||||
func NewDBBudget(storage BudgetStorage, weeklyLimit func() float64, notify BudgetNotifier, now func() time.Time) *DBBudget {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &DBBudget{
|
||||
storage: storage,
|
||||
weeklyLimit: weeklyLimit,
|
||||
notify: notify,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Check returns ErrBudgetExceeded if the caller has spent at least
|
||||
// weeklyLimit seconds in the current rolling 7-day window.
|
||||
//
|
||||
// Why anonymous callerID="" is unbudgeted: scheduler-driven and
|
||||
// system-initiated runs don't have a Discord user to bill; charging
|
||||
// "system" would conflate them with a real user. The scheduler sets
|
||||
// CallerID to the skill owner where applicable, so cron-loop
|
||||
// abusiveness still consumes the owner's budget.
|
||||
//
|
||||
// Why cap<=0 means "disabled": operator wants a runtime kill-switch.
|
||||
// Setting the convar to "0" turns enforcement off without restart.
|
||||
//
|
||||
// Test: Get returns nil → Check returns nil; Get returns row with
|
||||
// SecondsUsed >= cap → Check returns ErrBudgetExceeded and notify is
|
||||
// invoked; window expired (>=7d) → Check returns nil regardless of
|
||||
// SecondsUsed.
|
||||
func (b *DBBudget) Check(ctx context.Context, callerID string) error {
|
||||
if callerID == "" {
|
||||
return nil
|
||||
}
|
||||
bud, err := b.storage.Get(ctx, callerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("budget: %w", err)
|
||||
}
|
||||
if bud != nil {
|
||||
if b.now().Sub(bud.WindowStart) < 7*24*time.Hour {
|
||||
cap := b.weeklyLimit()
|
||||
if cap > 0 && bud.SecondsUsed >= cap {
|
||||
if b.notify != nil {
|
||||
b.notify(ctx, callerID, bud.SecondsUsed, cap)
|
||||
}
|
||||
return ErrBudgetExceeded
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit records the run's runtime against the caller's budget.
|
||||
// Failures are logged but never returned — budget accounting must
|
||||
// not break user-visible execution.
|
||||
//
|
||||
// Why callerID="" is a no-op: matches Check's anonymous-caller
|
||||
// shortcut; system runs don't get billed.
|
||||
//
|
||||
// Why runtimeSeconds<=0 is a no-op: a run that errored before
|
||||
// resolving a model has wallSecs near 0 in floating-point terms but
|
||||
// can also be exactly 0 (synthetic test fixtures). Skipping avoids
|
||||
// spurious 0-runs rows from short-lived failures.
|
||||
//
|
||||
// Test: Commit(50) → Get reports SecondsUsed=50; storage failure
|
||||
// surfaces only as a slog.Warn (no panic, no return).
|
||||
func (b *DBBudget) Commit(ctx context.Context, callerID string, runtimeSeconds float64) {
|
||||
if callerID == "" || runtimeSeconds <= 0 {
|
||||
return
|
||||
}
|
||||
if err := b.storage.Add(ctx, callerID, runtimeSeconds, b.now()); err != nil {
|
||||
slog.Warn("skills budget: commit failed", "user", callerID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package budget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDBBudgetRollingWindow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mem := NewMemory()
|
||||
now := time.Now()
|
||||
clock := func() time.Time { return now }
|
||||
b := NewDBBudget(mem, func() float64 { return 100 }, nil, clock)
|
||||
|
||||
// Under cap: allowed.
|
||||
if err := b.Check(ctx, "u"); err != nil {
|
||||
t.Fatalf("fresh caller should pass: %v", err)
|
||||
}
|
||||
b.Commit(ctx, "u", 60)
|
||||
if err := b.Check(ctx, "u"); err != nil {
|
||||
t.Fatalf("60/100 should pass: %v", err)
|
||||
}
|
||||
// Over cap: rejected.
|
||||
b.Commit(ctx, "u", 50) // 110 total
|
||||
if err := b.Check(ctx, "u"); !errors.Is(err, ErrBudgetExceeded) {
|
||||
t.Fatalf("110/100 should be ErrBudgetExceeded, got %v", err)
|
||||
}
|
||||
// Window rolls over after 7 days: allowed again.
|
||||
now = now.Add(8 * 24 * time.Hour)
|
||||
b.Commit(ctx, "u", 1) // triggers rollover inside Add
|
||||
if err := b.Check(ctx, "u"); err != nil {
|
||||
t.Fatalf("after window rollover should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoOpBudgetAlwaysAllows(t *testing.T) {
|
||||
b := NewNoOpBudget()
|
||||
if err := b.Check(context.Background(), "anyone"); err != nil {
|
||||
t.Fatalf("NoOp must always allow: %v", err)
|
||||
}
|
||||
b.Commit(context.Background(), "anyone", 1e9) // no-op
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package budget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Memory is a zero-dependency in-process BudgetStorage: per-user rolling-window
|
||||
// usage held in memory (lost on restart). The default behind DBBudget for a
|
||||
// light host or tests; mort uses its GORM Storage, contrib/store adds SQLite.
|
||||
type Memory struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]*SkillBudget
|
||||
}
|
||||
|
||||
// NewMemory returns an empty in-memory BudgetStorage.
|
||||
func NewMemory() *Memory { return &Memory{rows: map[string]*SkillBudget{}} }
|
||||
|
||||
var _ BudgetStorage = (*Memory)(nil)
|
||||
|
||||
func (m *Memory) Initialize(context.Context) error { return nil }
|
||||
|
||||
func (m *Memory) Get(_ context.Context, userID string) (*SkillBudget, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
r, ok := m.rows[userID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
cp := *r // copy out so callers can't mutate our row
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) Add(_ context.Context, userID string, secondsUsed float64, now time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
r, ok := m.rows[userID]
|
||||
if !ok {
|
||||
m.rows[userID] = &SkillBudget{
|
||||
UserID: userID, WindowStart: now,
|
||||
SecondsUsed: secondsUsed, RunsCount: 1, UpdatedAt: now,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Roll the window over if it's older than the window length.
|
||||
if now.Sub(r.WindowStart) >= budgetWindow {
|
||||
r.WindowStart = now
|
||||
r.SecondsUsed = 0
|
||||
r.RunsCount = 0
|
||||
}
|
||||
r.SecondsUsed += secondsUsed
|
||||
r.RunsCount++
|
||||
r.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package budget
|
||||
|
||||
import "gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
|
||||
// The budget trackers plug directly into run.Ports.Budget (Check/Commit match).
|
||||
var (
|
||||
_ run.Budget = NoOpBudget{}
|
||||
_ run.Budget = (*DBBudget)(nil)
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
package budget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BudgetStorage is the persistence seam behind DBBudget: one budget row per
|
||||
// user, with an atomic Add that rolls the 7-day window over transparently. Mort
|
||||
// backs this with GORM/MySQL (the skill_budgets table); Memory() is the
|
||||
// zero-dependency default; contrib/store adds a durable SQLite one.
|
||||
type BudgetStorage interface {
|
||||
// Initialize runs any schema setup. Safe to call repeatedly.
|
||||
Initialize(ctx context.Context) error
|
||||
// Get returns the user's current budget row, or (nil, nil) if none exists.
|
||||
Get(ctx context.Context, userID string) (*SkillBudget, error)
|
||||
// Add increments seconds_used + runs_count atomically, rolling the window
|
||||
// over when WindowStart is older than 7 days (reset to now, fresh count).
|
||||
// Creates the row if absent.
|
||||
Add(ctx context.Context, userID string, secondsUsed float64, now time.Time) error
|
||||
}
|
||||
|
||||
// SkillBudget is one user's rolling-window usage row.
|
||||
type SkillBudget struct {
|
||||
UserID string
|
||||
WindowStart time.Time
|
||||
SecondsUsed float64
|
||||
RunsCount int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// budgetWindow is the rolling window length the storage rolls over at.
|
||||
const budgetWindow = 7 * 24 * time.Hour
|
||||
@@ -0,0 +1,356 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/audit"
|
||||
)
|
||||
|
||||
// auditStore is the SQLite-backed audit.Storage: one row per run (+ a JSON
|
||||
// `inputs` blob), one row per log event. The run-list/filter/walk queries are
|
||||
// indexed on the columns they filter; the log payload is a JSON blob.
|
||||
type auditStore struct{ db *sql.DB }
|
||||
|
||||
// Audit returns a durable audit.Storage backed by this database.
|
||||
func (d *DB) Audit() audit.Storage { return &auditStore{db: d.sql} }
|
||||
|
||||
var _ audit.Storage = (*auditStore)(nil)
|
||||
|
||||
func (s *auditStore) Initialize(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS skill_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
skill_id TEXT NOT NULL DEFAULT '',
|
||||
caller_id TEXT NOT NULL DEFAULT '',
|
||||
channel_id TEXT NOT NULL DEFAULT '',
|
||||
parent_run_id TEXT NOT NULL DEFAULT '',
|
||||
inputs TEXT NOT NULL DEFAULT '{}',
|
||||
started_at INTEGER NOT NULL DEFAULT 0,
|
||||
finished_at INTEGER NOT NULL DEFAULT 0, -- 0 = still running
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
output TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
tool_calls INTEGER NOT NULL DEFAULT 0,
|
||||
runtime_seconds REAL NOT NULL DEFAULT 0,
|
||||
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_thinking_tokens INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_skill ON skill_runs(skill_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_caller ON skill_runs(caller_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_parent ON skill_runs(parent_run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_started ON skill_runs(started_at);
|
||||
CREATE TABLE IF NOT EXISTS skill_run_logs (
|
||||
run_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (run_id, seq)
|
||||
);`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auditStore.Initialize: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unixOrZero(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
||||
func (s *auditStore) StartRun(ctx context.Context, r audit.SkillRun) error {
|
||||
inputs, _ := json.Marshal(r.Inputs)
|
||||
var fin int64
|
||||
if r.FinishedAt != nil {
|
||||
fin = unixOrZero(*r.FinishedAt)
|
||||
}
|
||||
status := r.Status
|
||||
if status == "" {
|
||||
status = "running"
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO skill_runs (id, skill_id, caller_id, channel_id, parent_run_id, inputs, started_at, finished_at,
|
||||
status, output, error, tool_calls, runtime_seconds, total_input_tokens, total_output_tokens, total_thinking_tokens)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
skill_id=excluded.skill_id, caller_id=excluded.caller_id, channel_id=excluded.channel_id,
|
||||
parent_run_id=excluded.parent_run_id, inputs=excluded.inputs, started_at=excluded.started_at`,
|
||||
r.ID, r.SkillID, r.CallerID, r.ChannelID, r.ParentRunID, string(inputs), unixOrZero(r.StartedAt), fin,
|
||||
status, r.Output, r.Error, r.ToolCallsCount, r.RuntimeSeconds,
|
||||
r.TotalInputTokens, r.TotalOutputTokens, r.TotalThinkingTokens)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auditStore.StartRun: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *auditStore) FinishRun(ctx context.Context, runID string, st audit.RunStats) error {
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
UPDATE skill_runs SET finished_at=?, status=?, output=?, error=?, tool_calls=?, runtime_seconds=?,
|
||||
total_input_tokens=?, total_output_tokens=?, total_thinking_tokens=? WHERE id=?`,
|
||||
time.Now().Unix(), st.Status, st.Output, st.Error, st.ToolCalls, st.RuntimeSeconds,
|
||||
st.InputTokens, st.OutputTokens, st.ThinkingTokens, runID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auditStore.FinishRun: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return audit.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *auditStore) AppendLog(ctx context.Context, l audit.SkillRunLog) error {
|
||||
payload, _ := json.Marshal(l.Payload)
|
||||
created := unixOrZero(l.CreatedAt)
|
||||
if created == 0 {
|
||||
created = time.Now().Unix()
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT OR REPLACE INTO skill_run_logs (run_id, seq, event_type, payload, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
l.RunID, l.Sequence, l.EventType, string(payload), created)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auditStore.AppendLog: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCols is the SELECT column list matching scanRun.
|
||||
const runCols = `id, skill_id, caller_id, channel_id, parent_run_id, inputs, started_at, finished_at,
|
||||
status, output, error, tool_calls, runtime_seconds, total_input_tokens, total_output_tokens, total_thinking_tokens`
|
||||
|
||||
func scanRun(sc interface{ Scan(...any) error }) (*audit.SkillRun, error) {
|
||||
var r audit.SkillRun
|
||||
var inputs string
|
||||
var started, finished int64
|
||||
if err := sc.Scan(&r.ID, &r.SkillID, &r.CallerID, &r.ChannelID, &r.ParentRunID, &inputs,
|
||||
&started, &finished, &r.Status, &r.Output, &r.Error, &r.ToolCallsCount, &r.RuntimeSeconds,
|
||||
&r.TotalInputTokens, &r.TotalOutputTokens, &r.TotalThinkingTokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(inputs), &r.Inputs)
|
||||
r.StartedAt = time.Unix(started, 0).UTC()
|
||||
if finished > 0 {
|
||||
t := time.Unix(finished, 0).UTC()
|
||||
r.FinishedAt = &t
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *auditStore) GetRun(ctx context.Context, runID string) (*audit.SkillRun, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+runCols+` FROM skill_runs WHERE id = ?`, runID)
|
||||
r, err := scanRun(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, audit.ErrNotFound
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (s *auditStore) queryRuns(ctx context.Context, tail string, args ...any) ([]audit.SkillRun, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+runCols+` FROM skill_runs `+tail, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []audit.SkillRun
|
||||
for rows.Next() {
|
||||
r, err := scanRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *auditStore) ListLogsByRun(ctx context.Context, runID string) ([]audit.SkillRunLog, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT run_id, seq, event_type, payload, created_at FROM skill_run_logs WHERE run_id = ? ORDER BY seq`, runID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auditStore.ListLogsByRun: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []audit.SkillRunLog
|
||||
for rows.Next() {
|
||||
var l audit.SkillRunLog
|
||||
var payload string
|
||||
var created int64
|
||||
if err := rows.Scan(&l.RunID, &l.Sequence, &l.EventType, &payload, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(payload), &l.Payload)
|
||||
l.CreatedAt = time.Unix(created, 0).UTC()
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *auditStore) ListRunsBySkill(ctx context.Context, skillID string, limit int) ([]audit.SkillRun, error) {
|
||||
return s.ListRunsBySkillPaginated(ctx, skillID, 0, limit, false)
|
||||
}
|
||||
|
||||
func (s *auditStore) ListRunsBySkillPaginated(ctx context.Context, skillID string, offset, limit int, includeDryRun bool) ([]audit.SkillRun, error) {
|
||||
w := `WHERE skill_id = ?`
|
||||
args := []any{skillID}
|
||||
if !includeDryRun {
|
||||
w += ` AND status != 'dry_run'`
|
||||
}
|
||||
return s.queryRuns(ctx, w+` ORDER BY started_at DESC `+limitOffset(limit, offset), args...)
|
||||
}
|
||||
|
||||
func (s *auditStore) CountRunsBySkill(ctx context.Context, skillID string, includeDryRun bool) (int64, error) {
|
||||
q := `SELECT COUNT(*) FROM skill_runs WHERE skill_id = ?`
|
||||
if !includeDryRun {
|
||||
q += ` AND status != 'dry_run'`
|
||||
}
|
||||
var n int64
|
||||
err := s.db.QueryRowContext(ctx, q, skillID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *auditStore) ListRunsByCaller(ctx context.Context, callerID string, limit int) ([]audit.SkillRun, error) {
|
||||
return s.queryRuns(ctx, `WHERE caller_id = ? AND status != 'dry_run' ORDER BY started_at DESC `+limitOffset(limit, 0), callerID)
|
||||
}
|
||||
|
||||
func (s *auditStore) buildFilter(f audit.RunFilter) (string, []any) {
|
||||
var conds []string
|
||||
var args []any
|
||||
if !f.IncludeDryRun {
|
||||
conds = append(conds, `status != 'dry_run'`)
|
||||
}
|
||||
if f.Status != "" {
|
||||
conds = append(conds, `status = ?`)
|
||||
args = append(args, f.Status)
|
||||
}
|
||||
if f.SkillID != "" {
|
||||
conds = append(conds, `skill_id = ?`)
|
||||
args = append(args, f.SkillID)
|
||||
}
|
||||
if f.CallerID != "" {
|
||||
conds = append(conds, `caller_id = ?`)
|
||||
args = append(args, f.CallerID)
|
||||
}
|
||||
if f.ChannelID != "" {
|
||||
conds = append(conds, `channel_id = ?`)
|
||||
args = append(args, f.ChannelID)
|
||||
}
|
||||
if f.TopLevelOnly {
|
||||
conds = append(conds, `parent_run_id = ''`)
|
||||
}
|
||||
if !f.Since.IsZero() {
|
||||
conds = append(conds, `started_at >= ?`)
|
||||
args = append(args, f.Since.Unix())
|
||||
}
|
||||
if !f.Until.IsZero() {
|
||||
conds = append(conds, `started_at <= ?`)
|
||||
args = append(args, f.Until.Unix())
|
||||
}
|
||||
where := ""
|
||||
if len(conds) > 0 {
|
||||
where = `WHERE ` + strings.Join(conds, " AND ")
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func (s *auditStore) ListRunsFiltered(ctx context.Context, f audit.RunFilter, offset, limit int) ([]audit.SkillRun, error) {
|
||||
where, args := s.buildFilter(f)
|
||||
return s.queryRuns(ctx, where+` ORDER BY started_at DESC `+limitOffset(limit, offset), args...)
|
||||
}
|
||||
|
||||
func (s *auditStore) CountRunsFiltered(ctx context.Context, f audit.RunFilter) (int64, error) {
|
||||
where, args := s.buildFilter(f)
|
||||
var n int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM skill_runs `+where, args...).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *auditStore) PurgeOlderThan(ctx context.Context, t time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM skill_runs WHERE finished_at > 0 AND finished_at < ?`, t.Unix())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auditStore.PurgeOlderThan: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
// Best-effort orphan-log cleanup.
|
||||
_, _ = s.db.ExecContext(ctx, `DELETE FROM skill_run_logs WHERE run_id NOT IN (SELECT id FROM skill_runs)`)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *auditStore) ListChildrenByParent(ctx context.Context, parentRunID string) ([]audit.SkillRun, error) {
|
||||
return s.queryRuns(ctx, `WHERE parent_run_id = ? ORDER BY started_at DESC`, parentRunID)
|
||||
}
|
||||
|
||||
func (s *auditStore) WalkParentChain(ctx context.Context, runID string) ([]audit.SkillRun, error) {
|
||||
var chain []audit.SkillRun
|
||||
seen := map[string]bool{}
|
||||
for id := runID; id != ""; {
|
||||
if seen[id] {
|
||||
break
|
||||
}
|
||||
seen[id] = true
|
||||
r, err := s.GetRun(ctx, id)
|
||||
if errors.Is(err, audit.ErrNotFound) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chain = append(chain, *r)
|
||||
id = r.ParentRunID
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
func (s *auditStore) ListFinishedRunsBefore(ctx context.Context, cutoff time.Time, limit int) ([]audit.SkillRun, error) {
|
||||
return s.queryRuns(ctx,
|
||||
`WHERE finished_at > 0 AND finished_at < ? ORDER BY started_at DESC `+limitOffset(limit, 0), cutoff.Unix())
|
||||
}
|
||||
|
||||
func (s *auditStore) LastRunBySkills(ctx context.Context, skillIDs []string, includeFailed bool) (map[string]time.Time, error) {
|
||||
out := map[string]time.Time{}
|
||||
if len(skillIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
q := `SELECT skill_id, MAX(started_at) FROM skill_runs WHERE skill_id IN (` +
|
||||
strings.TrimSuffix(strings.Repeat("?,", len(skillIDs)), ",") + `)`
|
||||
args := make([]any, 0, len(skillIDs))
|
||||
for _, id := range skillIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
if !includeFailed {
|
||||
q += ` AND status NOT IN ('error','timeout')`
|
||||
}
|
||||
q += ` GROUP BY skill_id`
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auditStore.LastRunBySkills: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var ts int64
|
||||
if err := rows.Scan(&id, &ts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = time.Unix(ts, 0).UTC()
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// limitOffset renders an optional LIMIT/OFFSET clause (limit<=0 = no limit).
|
||||
func limitOffset(limit, offset int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if offset > 0 {
|
||||
return fmt.Sprintf("LIMIT %d OFFSET %d", limit, offset)
|
||||
}
|
||||
return fmt.Sprintf("LIMIT %d", limit)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/audit"
|
||||
)
|
||||
|
||||
func TestSQLiteAuditStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
st := db.Audit()
|
||||
if err := st.Initialize(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
// parent run
|
||||
if err := st.StartRun(ctx, audit.SkillRun{ID: "r1", SkillID: "agent-x", CallerID: "c1",
|
||||
Inputs: map[string]any{"q": "hi"}, StartedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// child run
|
||||
st.StartRun(ctx, audit.SkillRun{ID: "r2", SkillID: "skill-y", CallerID: "c1", ParentRunID: "r1", StartedAt: now.Add(time.Second)})
|
||||
|
||||
st.AppendLog(ctx, audit.SkillRunLog{RunID: "r1", Sequence: 1, EventType: "step", Payload: map[string]any{"i": 1}, CreatedAt: now})
|
||||
if err := st.FinishRun(ctx, "r1", audit.RunStats{Status: "ok", Output: "done", ToolCalls: 2, InputTokens: 10, OutputTokens: 5}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := st.GetRun(ctx, "r1")
|
||||
if err != nil || got.Status != "ok" || got.Output != "done" || got.FinishedAt == nil ||
|
||||
got.Inputs["q"] != "hi" || got.TotalInputTokens != 10 {
|
||||
t.Fatalf("GetRun: %v %+v", err, got)
|
||||
}
|
||||
if logs, _ := st.ListLogsByRun(ctx, "r1"); len(logs) != 1 || logs[0].EventType != "step" {
|
||||
t.Errorf("ListLogsByRun = %+v", logs)
|
||||
}
|
||||
if kids, _ := st.ListChildrenByParent(ctx, "r1"); len(kids) != 1 || kids[0].ID != "r2" {
|
||||
t.Errorf("ListChildrenByParent = %+v", kids)
|
||||
}
|
||||
if chain, _ := st.WalkParentChain(ctx, "r2"); len(chain) != 2 || chain[1].ID != "r1" {
|
||||
t.Errorf("WalkParentChain = %+v", chain)
|
||||
}
|
||||
if byCaller, _ := st.ListRunsByCaller(ctx, "c1", 10); len(byCaller) != 2 {
|
||||
t.Errorf("ListRunsByCaller = %d, want 2", len(byCaller))
|
||||
}
|
||||
// filter: top-level only
|
||||
tl, _ := st.ListRunsFiltered(ctx, audit.RunFilter{TopLevelOnly: true}, 0, 10)
|
||||
if len(tl) != 1 || tl[0].ID != "r1" {
|
||||
t.Errorf("TopLevelOnly filter = %+v", tl)
|
||||
}
|
||||
// last-run map
|
||||
last, _ := st.LastRunBySkills(ctx, []string{"agent-x", "skill-y"}, true)
|
||||
if _, ok := last["agent-x"]; !ok {
|
||||
t.Errorf("LastRunBySkills missing agent-x: %+v", last)
|
||||
}
|
||||
if n, _ := st.CountRunsBySkill(ctx, "agent-x", false); n != 1 {
|
||||
t.Errorf("CountRunsBySkill = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/budget"
|
||||
)
|
||||
|
||||
// budgetStore is the SQLite-backed budget.BudgetStorage.
|
||||
type budgetStore struct{ db *sql.DB }
|
||||
|
||||
// Budget returns a durable budget.BudgetStorage backed by this database.
|
||||
func (d *DB) Budget() budget.BudgetStorage { return &budgetStore{db: d.sql} }
|
||||
|
||||
var _ budget.BudgetStorage = (*budgetStore)(nil)
|
||||
|
||||
func (s *budgetStore) Initialize(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS skill_budgets (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
window_start INTEGER NOT NULL, -- unix seconds
|
||||
seconds_used REAL NOT NULL,
|
||||
runs_count INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("budgetStore.Initialize: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *budgetStore) Get(ctx context.Context, userID string) (*budget.SkillBudget, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT window_start, seconds_used, runs_count, updated_at FROM skill_budgets WHERE user_id = ?`, userID)
|
||||
var ws, ua int64
|
||||
var used float64
|
||||
var runs int
|
||||
switch err := row.Scan(&ws, &used, &runs, &ua); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, nil // no row yet — documented (nil, nil)
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("budgetStore.Get: %w", err)
|
||||
}
|
||||
return &budget.SkillBudget{
|
||||
UserID: userID,
|
||||
WindowStart: time.Unix(ws, 0).UTC(),
|
||||
SecondsUsed: used,
|
||||
RunsCount: runs,
|
||||
UpdatedAt: time.Unix(ua, 0).UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Add increments usage atomically, rolling the 7-day window over inside one
|
||||
// transaction so concurrent Adds can't race the read-modify-write.
|
||||
func (s *budgetStore) Add(ctx context.Context, userID string, secondsUsed float64, now time.Time) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("budgetStore.Add: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after Commit
|
||||
|
||||
var ws int64
|
||||
var used float64
|
||||
var runs int
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT window_start, seconds_used, runs_count FROM skill_budgets WHERE user_id = ?`, userID).
|
||||
Scan(&ws, &used, &runs)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
ws, used, runs = now.Unix(), 0, 0
|
||||
case err != nil:
|
||||
return fmt.Errorf("budgetStore.Add: select: %w", err)
|
||||
}
|
||||
// Roll the window over if older than 7 days.
|
||||
if now.Sub(time.Unix(ws, 0)) >= 7*24*time.Hour {
|
||||
ws, used, runs = now.Unix(), 0, 0
|
||||
}
|
||||
used += secondsUsed
|
||||
runs++
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO skill_budgets (user_id, window_start, seconds_used, runs_count, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
window_start = excluded.window_start,
|
||||
seconds_used = excluded.seconds_used,
|
||||
runs_count = excluded.runs_count,
|
||||
updated_at = excluded.updated_at`,
|
||||
userID, ws, used, runs, now.Unix()); err != nil {
|
||||
return fmt.Errorf("budgetStore.Add: upsert: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("budgetStore.Add: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/budget"
|
||||
)
|
||||
|
||||
// TestSQLiteBudgetConformance runs the budget battery over the SQLite store and
|
||||
// asserts the same rolling-window contract the in-memory store must satisfy.
|
||||
func TestSQLiteBudgetConformance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
st := db.Budget()
|
||||
if err := st.Initialize(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
b := budget.NewDBBudget(st, func() float64 { return 100 }, nil, func() time.Time { return now })
|
||||
|
||||
if err := b.Check(ctx, "u"); err != nil {
|
||||
t.Fatalf("fresh caller should pass: %v", err)
|
||||
}
|
||||
b.Commit(ctx, "u", 60)
|
||||
if err := b.Check(ctx, "u"); err != nil {
|
||||
t.Fatalf("60/100 should pass: %v", err)
|
||||
}
|
||||
b.Commit(ctx, "u", 50) // 110 total
|
||||
if err := b.Check(ctx, "u"); !errors.Is(err, budget.ErrBudgetExceeded) {
|
||||
t.Fatalf("110/100 should be ErrBudgetExceeded, got %v", err)
|
||||
}
|
||||
|
||||
// Direct Get reflects the persisted row.
|
||||
row, err := st.Get(ctx, "u")
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("Get: %v %+v", err, row)
|
||||
}
|
||||
if row.SecondsUsed != 110 || row.RunsCount != 2 {
|
||||
t.Errorf("row = %+v, want seconds=110 runs=2", row)
|
||||
}
|
||||
|
||||
// Window rolls over after 7 days.
|
||||
now = now.Add(8 * 24 * time.Hour)
|
||||
b.Commit(ctx, "u", 1)
|
||||
if err := b.Check(ctx, "u"); err != nil {
|
||||
t.Fatalf("after rollover should pass: %v", err)
|
||||
}
|
||||
row, _ = st.Get(ctx, "u")
|
||||
if row.SecondsUsed != 1 || row.RunsCount != 1 {
|
||||
t.Errorf("post-rollover row = %+v, want seconds=1 runs=1", row)
|
||||
}
|
||||
|
||||
// Unknown user -> (nil, nil).
|
||||
if r, err := st.Get(ctx, "nobody"); err != nil || r != nil {
|
||||
t.Errorf("Get(unknown) = %+v %v, want nil,nil", r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
module gitea.stevedudenhoeffer.com/steve/executus/contrib/store
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
gitea.stevedudenhoeffer.com/steve/executus v0.0.0
|
||||
modernc.org/sqlite v1.34.4
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.1 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
google.golang.org/genai v1.59.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
// Co-developed against the local checkout; dropped (pinned) at executus v0.1.0.
|
||||
replace gitea.stevedudenhoeffer.com/steve/executus => ../../
|
||||
@@ -0,0 +1,105 @@
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
|
||||
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3 h1:KYKIFFRsXzbbBJVDa99+Fhy0zxl9G0xV/MCrLipsLL4=
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
google.golang.org/genai v1.59.0 h1:xp+ydkJFW8hO0hTUaAkr8TrLM9HFP3NYAwFhPd0nDqA=
|
||||
google.golang.org/genai v1.59.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
|
||||
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,167 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/persona"
|
||||
)
|
||||
|
||||
// personaStore is the SQLite-backed persona.Storage. It stores each Agent as a
|
||||
// JSON blob in `data` with a handful of extracted, indexed columns for the
|
||||
// query methods — so the FULL struct round-trips (no domain↔GORM↔DB field-loss
|
||||
// footgun) while owner/name/webhook/schedule lookups stay indexable.
|
||||
type personaStore struct{ db *sql.DB }
|
||||
|
||||
// Personas returns a durable persona.Storage backed by this database.
|
||||
func (d *DB) Personas() persona.Storage { return &personaStore{db: d.sql} }
|
||||
|
||||
var _ persona.Storage = (*personaStore)(nil)
|
||||
|
||||
func (s *personaStore) InitializeAgentStorage(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
webhook_secret TEXT NOT NULL DEFAULT '',
|
||||
chatbot_channel_filter TEXT NOT NULL DEFAULT '',
|
||||
schedule TEXT NOT NULL DEFAULT '',
|
||||
next_run_at INTEGER NOT NULL DEFAULT 0, -- unix seconds; 0 = unset
|
||||
data TEXT NOT NULL -- full Agent as JSON
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_owner ON agents(owner_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_owner_name ON agents(owner_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_sched ON agents(schedule, next_run_at);`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("personaStore.Initialize: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *personaStore) SaveAgent(ctx context.Context, a *persona.Agent) error {
|
||||
blob, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return fmt.Errorf("personaStore.SaveAgent: marshal: %w", err)
|
||||
}
|
||||
var next int64
|
||||
if a.NextRunAt != nil && !a.NextRunAt.IsZero() {
|
||||
next = a.NextRunAt.Unix()
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO agents (id, owner_id, name, webhook_secret, chatbot_channel_filter, schedule, next_run_at, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
owner_id=excluded.owner_id, name=excluded.name, webhook_secret=excluded.webhook_secret,
|
||||
chatbot_channel_filter=excluded.chatbot_channel_filter, schedule=excluded.schedule,
|
||||
next_run_at=excluded.next_run_at, data=excluded.data`,
|
||||
a.ID, a.OwnerID, a.Name, a.WebhookSecret, a.ChatbotChannelFilter, a.Schedule, next, string(blob))
|
||||
if err != nil {
|
||||
return fmt.Errorf("personaStore.SaveAgent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanAgents unmarshals the `data` column of every row in rows.
|
||||
func scanAgents(rows *sql.Rows) ([]*persona.Agent, error) {
|
||||
defer rows.Close()
|
||||
var out []*persona.Agent
|
||||
for rows.Next() {
|
||||
var blob string
|
||||
if err := rows.Scan(&blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a persona.Agent
|
||||
if err := json.Unmarshal([]byte(blob), &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *personaStore) getOne(ctx context.Context, where string, arg ...any) (*persona.Agent, error) {
|
||||
var blob string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT data FROM agents WHERE `+where, arg...).Scan(&blob)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, persona.ErrNotFound
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
var a persona.Agent
|
||||
if err := json.Unmarshal([]byte(blob), &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *personaStore) GetAgent(ctx context.Context, id string) (*persona.Agent, error) {
|
||||
return s.getOne(ctx, "id = ?", id)
|
||||
}
|
||||
|
||||
func (s *personaStore) GetAgentByName(ctx context.Context, ownerID, name string) (*persona.Agent, error) {
|
||||
return s.getOne(ctx, "owner_id = ? AND name = ?", ownerID, name)
|
||||
}
|
||||
|
||||
func (s *personaStore) GetAgentByWebhookSecret(ctx context.Context, secret string) (*persona.Agent, error) {
|
||||
if secret == "" {
|
||||
return nil, persona.ErrNotFound
|
||||
}
|
||||
return s.getOne(ctx, "webhook_secret = ?", secret)
|
||||
}
|
||||
|
||||
func (s *personaStore) ListAgents(ctx context.Context, ownerID string) ([]*persona.Agent, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT data FROM agents WHERE owner_id = ? ORDER BY name`, ownerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("personaStore.ListAgents: %w", err)
|
||||
}
|
||||
return scanAgents(rows)
|
||||
}
|
||||
|
||||
func (s *personaStore) ListAllAgents(ctx context.Context) ([]*persona.Agent, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT data FROM agents ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("personaStore.ListAllAgents: %w", err)
|
||||
}
|
||||
return scanAgents(rows)
|
||||
}
|
||||
|
||||
func (s *personaStore) DeleteAgent(ctx context.Context, id string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM agents WHERE id = ?`, id); err != nil {
|
||||
return fmt.Errorf("personaStore.DeleteAgent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *personaStore) ListAgentsByChatbotChannelFilter(ctx context.Context) ([]*persona.Agent, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT data FROM agents WHERE chatbot_channel_filter != '' ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("personaStore.ListAgentsByChatbotChannelFilter: %w", err)
|
||||
}
|
||||
return scanAgents(rows)
|
||||
}
|
||||
|
||||
func (s *personaStore) ListScheduledAgents(ctx context.Context, dueBefore time.Time) ([]*persona.Agent, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT data FROM agents WHERE schedule != '' AND next_run_at > 0 AND next_run_at <= ? ORDER BY next_run_at`,
|
||||
dueBefore.Unix())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("personaStore.ListScheduledAgents: %w", err)
|
||||
}
|
||||
return scanAgents(rows)
|
||||
}
|
||||
|
||||
func (s *personaStore) MarkAgentScheduledRun(ctx context.Context, agentID string, ranAt, nextAt time.Time) error {
|
||||
a, err := s.GetAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.LastScheduledRunAt = &ranAt
|
||||
a.NextRunAt = &nextAt
|
||||
return s.SaveAgent(ctx, a)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/persona"
|
||||
)
|
||||
|
||||
func TestSQLitePersonaStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
st := db.Personas()
|
||||
if err := st.InitializeAgentStorage(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Full struct round-trips through the JSON blob (incl. nested + map fields).
|
||||
a := &persona.Agent{
|
||||
ID: "a1", Name: "helper", OwnerID: "o1", SystemPrompt: "be nice",
|
||||
ModelTier: "fast", SkillPalette: []string{"animate"},
|
||||
StateReactEmoji: map[string]string{"running": "⏳"},
|
||||
ChatbotChannelFilter: "general",
|
||||
}
|
||||
if err := st.SaveAgent(ctx, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetAgent(ctx, "a1")
|
||||
if err != nil || got.SystemPrompt != "be nice" || len(got.SkillPalette) != 1 ||
|
||||
got.StateReactEmoji["running"] != "⏳" {
|
||||
t.Fatalf("round-trip lost fields: %+v (err %v)", got, err)
|
||||
}
|
||||
if byName, err := st.GetAgentByName(ctx, "o1", "helper"); err != nil || byName.ID != "a1" {
|
||||
t.Fatalf("GetAgentByName: %v %+v", err, byName)
|
||||
}
|
||||
if cf, _ := st.ListAgentsByChatbotChannelFilter(ctx); len(cf) != 1 {
|
||||
t.Errorf("ListAgentsByChatbotChannelFilter = %d, want 1", len(cf))
|
||||
}
|
||||
|
||||
// Scheduling: due query + MarkAgentScheduledRun round-trip.
|
||||
now := time.Now().UTC()
|
||||
sched := &persona.Agent{ID: "s1", Name: "cron", OwnerID: "o1", Schedule: "0 * * * *"}
|
||||
due := now.Add(-time.Minute)
|
||||
sched.NextRunAt = &due
|
||||
if err := st.SaveAgent(ctx, sched); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dueList, _ := st.ListScheduledAgents(ctx, now)
|
||||
if len(dueList) != 1 || dueList[0].ID != "s1" {
|
||||
t.Fatalf("ListScheduledAgents = %+v", dueList)
|
||||
}
|
||||
next := now.Add(time.Hour)
|
||||
if err := st.MarkAgentScheduledRun(ctx, "s1", now, next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again, _ := st.ListScheduledAgents(ctx, now); len(again) != 0 {
|
||||
t.Errorf("after MarkAgentScheduledRun, nothing should be due before now: %+v", again)
|
||||
}
|
||||
|
||||
if err := st.DeleteAgent(ctx, "a1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.GetAgent(ctx, "a1"); err != persona.ErrNotFound {
|
||||
t.Errorf("GetAgent after delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/skill"
|
||||
)
|
||||
|
||||
// skillStore is the SQLite-backed skill.SkillStore. Same JSON-blob + indexed
|
||||
// columns approach as personaStore: the full Skill round-trips, lookups stay
|
||||
// indexed. Versions live in their own table (each SkillVersion embeds a full
|
||||
// Skill snapshot, stored as a JSON blob).
|
||||
type skillStore struct{ db *sql.DB }
|
||||
|
||||
// Skills returns a durable skill.SkillStore backed by this database.
|
||||
func (d *DB) Skills() skill.SkillStore { return &skillStore{db: d.sql} }
|
||||
|
||||
var _ skill.SkillStore = (*skillStore)(nil)
|
||||
|
||||
func (s *skillStore) Initialize(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
visibility TEXT NOT NULL DEFAULT '',
|
||||
chatbot INTEGER NOT NULL DEFAULT 0, -- ExposeAsChatbotTool
|
||||
schedule TEXT NOT NULL DEFAULT '',
|
||||
next_run_at INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_skills_owner ON skills(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_skills_vis ON skills(visibility);
|
||||
CREATE INDEX IF NOT EXISTS idx_skills_sched ON skills(schedule, next_run_at);
|
||||
CREATE TABLE IF NOT EXISTS skill_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
skill_id TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '',
|
||||
seq INTEGER NOT NULL, -- append order, for newest-first
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_versions_skill ON skill_versions(skill_id, seq);`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skillStore.Initialize: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillStore) Save(ctx context.Context, sk *skill.Skill) error {
|
||||
blob, err := json.Marshal(sk)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skillStore.Save: marshal: %w", err)
|
||||
}
|
||||
var next int64
|
||||
if !sk.NextRunAt.IsZero() {
|
||||
next = sk.NextRunAt.Unix()
|
||||
}
|
||||
chatbot := 0
|
||||
if sk.ExposeAsChatbotTool {
|
||||
chatbot = 1
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO skills (id, owner_id, name, source, visibility, chatbot, schedule, next_run_at, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
owner_id=excluded.owner_id, name=excluded.name, source=excluded.source,
|
||||
visibility=excluded.visibility, chatbot=excluded.chatbot, schedule=excluded.schedule,
|
||||
next_run_at=excluded.next_run_at, data=excluded.data`,
|
||||
sk.ID, sk.OwnerID, sk.Name, string(sk.Source), string(sk.Visibility), chatbot,
|
||||
sk.Schedule, next, string(blob))
|
||||
if err != nil {
|
||||
return fmt.Errorf("skillStore.Save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanSkills(rows *sql.Rows) ([]skill.Skill, error) {
|
||||
defer rows.Close()
|
||||
var out []skill.Skill
|
||||
for rows.Next() {
|
||||
var blob string
|
||||
if err := rows.Scan(&blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sk skill.Skill
|
||||
if err := json.Unmarshal([]byte(blob), &sk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sk)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *skillStore) getOne(ctx context.Context, where string, arg ...any) (*skill.Skill, error) {
|
||||
var blob string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT data FROM skills WHERE `+where, arg...).Scan(&blob)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, skill.ErrNotFound
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
var sk skill.Skill
|
||||
if err := json.Unmarshal([]byte(blob), &sk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sk, nil
|
||||
}
|
||||
|
||||
func (s *skillStore) Get(ctx context.Context, id string) (*skill.Skill, error) {
|
||||
return s.getOne(ctx, "id = ?", id)
|
||||
}
|
||||
|
||||
func (s *skillStore) GetByName(ctx context.Context, ownerID, name string) (*skill.Skill, error) {
|
||||
return s.getOne(ctx, "owner_id = ? AND name = ?", ownerID, name)
|
||||
}
|
||||
|
||||
func (s *skillStore) ListBuiltinByName(ctx context.Context, name string) (*skill.Skill, error) {
|
||||
return s.getOne(ctx, "source = ? AND name = ?", string(skill.SourceBuiltin), name)
|
||||
}
|
||||
|
||||
func (s *skillStore) Delete(ctx context.Context, id string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM skills WHERE id = ?`, id); err != nil {
|
||||
return fmt.Errorf("skillStore.Delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillStore) query(ctx context.Context, where string, arg ...any) ([]skill.Skill, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT data FROM skills WHERE `+where+` ORDER BY name`, arg...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanSkills(rows)
|
||||
}
|
||||
|
||||
func (s *skillStore) ListByOwner(ctx context.Context, ownerID string) ([]skill.Skill, error) {
|
||||
return s.query(ctx, "owner_id = ?", ownerID)
|
||||
}
|
||||
|
||||
func (s *skillStore) ListPublic(ctx context.Context) ([]skill.Skill, error) {
|
||||
return s.query(ctx, "visibility = ?", string(skill.VisibilityPublic))
|
||||
}
|
||||
|
||||
func (s *skillStore) ListChatbotExposed(ctx context.Context) ([]skill.Skill, error) {
|
||||
return s.query(ctx, "chatbot = 1")
|
||||
}
|
||||
|
||||
// ListSharedWith loads visibility=shared rows and filters SharedWith in Go (the
|
||||
// shared set per skill is small; avoids a JSON-array query).
|
||||
func (s *skillStore) ListSharedWith(ctx context.Context, memberID string) ([]skill.Skill, error) {
|
||||
shared, err := s.query(ctx, "visibility = ?", string(skill.VisibilityShared))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := shared[:0]
|
||||
for _, sk := range shared {
|
||||
for _, id := range sk.SharedWith {
|
||||
if id == memberID {
|
||||
out = append(out, sk)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *skillStore) ListDueScheduled(ctx context.Context, now time.Time) ([]skill.Skill, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT data FROM skills WHERE schedule != '' AND next_run_at > 0 AND next_run_at <= ? ORDER BY next_run_at`,
|
||||
now.Unix())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skillStore.ListDueScheduled: %w", err)
|
||||
}
|
||||
return scanSkills(rows)
|
||||
}
|
||||
|
||||
func (s *skillStore) MarkScheduledRun(ctx context.Context, skillID string, ranAt, nextAt time.Time) error {
|
||||
sk, err := s.Get(ctx, skillID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sk.LastScheduledRunAt = ranAt
|
||||
sk.NextRunAt = nextAt
|
||||
return s.Save(ctx, sk)
|
||||
}
|
||||
|
||||
func (s *skillStore) AppendVersion(ctx context.Context, sv skill.SkillVersion) error {
|
||||
blob, err := json.Marshal(sv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("skillStore.AppendVersion: marshal: %w", err)
|
||||
}
|
||||
// seq = current max+1 for this skill (newest-first ordering key).
|
||||
var seq int64
|
||||
_ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq)
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO skill_versions (id, skill_id, version, seq, data) VALUES (?, ?, ?, ?, ?)`,
|
||||
sv.ID, sv.SkillID, sv.Version, seq, string(blob)); err != nil {
|
||||
return fmt.Errorf("skillStore.AppendVersion: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillStore) ListVersionsBySkill(ctx context.Context, skillID string, limit int) ([]skill.SkillVersion, error) {
|
||||
q := `SELECT data FROM skill_versions WHERE skill_id = ? ORDER BY seq DESC`
|
||||
args := []any{skillID}
|
||||
if limit > 0 {
|
||||
q += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skillStore.ListVersionsBySkill: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []skill.SkillVersion
|
||||
for rows.Next() {
|
||||
var blob string
|
||||
if err := rows.Scan(&blob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sv skill.SkillVersion
|
||||
if err := json.Unmarshal([]byte(blob), &sv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sv)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *skillStore) GetVersionByID(ctx context.Context, versionID string) (*skill.SkillVersion, error) {
|
||||
var blob string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT data FROM skill_versions WHERE id = ?`, versionID).Scan(&blob)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, skill.ErrNotFound
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
var sv skill.SkillVersion
|
||||
if err := json.Unmarshal([]byte(blob), &sv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sv, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/skill"
|
||||
)
|
||||
|
||||
func TestSQLiteSkillStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
st := db.Skills()
|
||||
if err := st.Initialize(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pub := &skill.Skill{ID: "a", Name: "pub", OwnerID: "o1", Visibility: skill.VisibilityPublic,
|
||||
Tools: []string{"summarize"}, ExposeAsChatbotTool: true}
|
||||
shared := &skill.Skill{ID: "b", Name: "shr", OwnerID: "o1", Visibility: skill.VisibilityShared, SharedWith: []string{"bob"}}
|
||||
if err := st.Save(ctx, pub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Save(ctx, shared); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := st.Get(ctx, "a")
|
||||
if err != nil || len(got.Tools) != 1 || !got.ExposeAsChatbotTool {
|
||||
t.Fatalf("round-trip: %v %+v", err, got)
|
||||
}
|
||||
if ps, _ := st.ListPublic(ctx); len(ps) != 1 || ps[0].ID != "a" {
|
||||
t.Errorf("ListPublic = %+v", ps)
|
||||
}
|
||||
if ss, _ := st.ListSharedWith(ctx, "bob"); len(ss) != 1 || ss[0].ID != "b" {
|
||||
t.Errorf("ListSharedWith(bob) = %+v", ss)
|
||||
}
|
||||
if ss, _ := st.ListSharedWith(ctx, "carol"); len(ss) != 0 {
|
||||
t.Errorf("ListSharedWith(carol) should be empty: %+v", ss)
|
||||
}
|
||||
if ce, _ := st.ListChatbotExposed(ctx); len(ce) != 1 {
|
||||
t.Errorf("ListChatbotExposed = %d, want 1", len(ce))
|
||||
}
|
||||
|
||||
// Versions newest-first + by id.
|
||||
st.AppendVersion(ctx, skill.SkillVersion{ID: "v1", SkillID: "a", Version: "1.0.0"})
|
||||
st.AppendVersion(ctx, skill.SkillVersion{ID: "v2", SkillID: "a", Version: "1.1.0"})
|
||||
vs, _ := st.ListVersionsBySkill(ctx, "a", 10)
|
||||
if len(vs) != 2 || vs[0].ID != "v2" {
|
||||
t.Errorf("versions newest-first: %+v", vs)
|
||||
}
|
||||
if gv, err := st.GetVersionByID(ctx, "v1"); err != nil || gv.Version != "1.0.0" {
|
||||
t.Errorf("GetVersionByID: %v %+v", err, gv)
|
||||
}
|
||||
|
||||
// Scheduling.
|
||||
now := time.Now().UTC()
|
||||
cron := &skill.Skill{ID: "c", Name: "cron", OwnerID: "o1", Schedule: "0 * * * *", NextRunAt: now.Add(-time.Minute)}
|
||||
st.Save(ctx, cron)
|
||||
if due, _ := st.ListDueScheduled(ctx, now); len(due) != 1 || due[0].ID != "c" {
|
||||
t.Fatalf("ListDueScheduled = %+v", due)
|
||||
}
|
||||
st.MarkScheduledRun(ctx, "c", now, now.Add(time.Hour))
|
||||
if due, _ := st.ListDueScheduled(ctx, now); len(due) != 0 {
|
||||
t.Errorf("after MarkScheduledRun nothing due: %+v", due)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package store provides durable, pure-Go SQLite implementations of executus's
|
||||
// battery store seams (audit, budget, persona, skill). It is a SEPARATE nested
|
||||
// module so the SQLite driver (modernc.org/sqlite — pure Go, no cgo) never
|
||||
// enters the executus core go.sum: a static-binary host (gadfly) that imports
|
||||
// only the core stays static, while a host that wants turnkey persistence
|
||||
// imports this module and gets every *Store seam backed by one SQLite file.
|
||||
//
|
||||
// db, _ := store.Open("file:executus.db?_pragma=busy_timeout(5000)")
|
||||
// defer db.Close()
|
||||
// budgetStore := db.Budget() // satisfies budget.BudgetStorage
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go driver, registered as "sqlite"
|
||||
)
|
||||
|
||||
// DB is a handle to one SQLite database backing the executus store seams. Each
|
||||
// accessor (Budget(), …) returns a seam implementation sharing this connection.
|
||||
// Safe for concurrent use (SQLite serializes writes; busy_timeout handles
|
||||
// contention). Construct with Open; close with Close.
|
||||
type DB struct {
|
||||
sql *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if absent) a SQLite database at dsn and returns a DB. A
|
||||
// dsn of ":memory:" yields an ephemeral in-memory database. The caller owns the
|
||||
// returned DB and must Close it.
|
||||
func Open(dsn string) (*DB, error) {
|
||||
sqldb, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open %q: %w", dsn, err)
|
||||
}
|
||||
if err := sqldb.Ping(); err != nil {
|
||||
sqldb.Close()
|
||||
return nil, fmt.Errorf("store: ping %q: %w", dsn, err)
|
||||
}
|
||||
return &DB{sql: sqldb}, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (d *DB) Close() error { return d.sql.Close() }
|
||||
|
||||
// SQL exposes the underlying *sql.DB for hosts that need direct access.
|
||||
func (d *DB) SQL() *sql.DB { return d.sql }
|
||||
@@ -5,24 +5,36 @@ go 1.26.2
|
||||
require (
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
golang.org/x/crypto v0.53.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.116.0 // indirect
|
||||
cloud.google.com/go/auth v0.9.3 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.1 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
google.golang.org/genai v1.59.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/grpc v1.66.2 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -1,130 +1,84 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs=
|
||||
cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3 h1:KYKIFFRsXzbbBJVDa99+Fhy0zxl9G0xV/MCrLipsLL4=
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genai v1.59.0 h1:xp+ydkJFW8hO0hTUaAkr8TrLM9HFP3NYAwFhPd0nDqA=
|
||||
google.golang.org/genai v1.59.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
|
||||
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package persona
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestToRunnable(t *testing.T) {
|
||||
a := &Agent{
|
||||
ID: "id1", Name: "helper", SystemPrompt: "be nice", ModelTier: "fast",
|
||||
MaxIterations: 5, MaxRuntime: 30 * time.Second,
|
||||
LowLevelTools: []string{"think"}, SkillPalette: []string{"animate"},
|
||||
CriticEnabled: true, CriticBackstopMultiplier: 2,
|
||||
Phases: []AgentPhase{{Name: "p1", ModelTier: "thinking", MaxIter: 3, Tools: []string{"now"}, Optional: true}},
|
||||
}
|
||||
r := a.ToRunnable()
|
||||
if r.ID != "id1" || r.ModelTier != "fast" || r.MaxIterations != 5 || !r.Critic.Enabled {
|
||||
t.Fatalf("ToRunnable mapping wrong: %+v", r)
|
||||
}
|
||||
if len(r.Phases) != 1 || r.Phases[0].MaxIterations != 3 || !r.Phases[0].Optional {
|
||||
t.Fatalf("phase mapping wrong: %+v", r.Phases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := NewMemory()
|
||||
a := &Agent{ID: "a1", Name: "n", OwnerID: "o1"}
|
||||
if err := m.SaveAgent(ctx, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := m.GetAgent(ctx, "a1")
|
||||
if err != nil || got.Name != "n" {
|
||||
t.Fatalf("GetAgent: %v %+v", err, got)
|
||||
}
|
||||
byName, err := m.GetAgentByName(ctx, "o1", "n")
|
||||
if err != nil || byName.ID != "a1" {
|
||||
t.Fatalf("GetAgentByName: %v %+v", err, byName)
|
||||
}
|
||||
list, _ := m.ListAgents(ctx, "o1")
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("ListAgents = %d", len(list))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package persona
|
||||
|
||||
import "gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
|
||||
// ToRunnable lowers an Agent persona into the kernel's run.RunnableAgent DTO —
|
||||
// the bridge that lets run.Executor run a persona WITHOUT importing this
|
||||
// battery (the inversion of mort's agentexec.Run(*agents.Agent)). It maps the
|
||||
// static shape only; per-run personalization, palette resolution, the critic,
|
||||
// audit, etc. are supplied separately via run.Ports.
|
||||
func (a *Agent) ToRunnable() run.RunnableAgent {
|
||||
ra := run.RunnableAgent{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
SystemPrompt: a.SystemPrompt,
|
||||
ModelTier: a.ModelTier,
|
||||
MaxIterations: a.MaxIterations,
|
||||
MaxRuntime: a.MaxRuntime,
|
||||
LowLevelTools: a.LowLevelTools,
|
||||
SkillPalette: a.SkillPalette,
|
||||
SubAgentPalette: a.SubAgentPalette,
|
||||
Critic: run.CriticConfig{
|
||||
Enabled: a.CriticEnabled,
|
||||
BackstopMultiplier: a.CriticBackstopMultiplier,
|
||||
},
|
||||
}
|
||||
for _, p := range a.Phases {
|
||||
ra.Phases = append(ra.Phases, run.Phase{
|
||||
Name: p.Name,
|
||||
SystemPrompt: p.SystemPrompt,
|
||||
ModelTier: p.ModelTier,
|
||||
MaxIterations: p.MaxIter,
|
||||
Tools: p.Tools,
|
||||
Optional: p.Optional,
|
||||
})
|
||||
}
|
||||
return ra
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package skill
|
||||
|
||||
// DefaultChatbotInputName is the input-param name a chatbot-exposed skill
|
||||
// receives the user's message under when its schema doesn't name one. Moved
|
||||
// from mort's chatbot_provider.go (a host concern) as a host-agnostic default.
|
||||
const DefaultChatbotInputName = "request"
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file holds the shared input-parsing primitives used by both the
|
||||
// chatbot exposure adapter (chatbot_provider.go) and the .skill Discord
|
||||
// command handler (commands.go) to construct a SkillInputs map from
|
||||
// caller-supplied raw values. Centralising here avoids the two paths
|
||||
// drifting in their type-coercion or required-check semantics.
|
||||
//
|
||||
// Two layers:
|
||||
//
|
||||
// - CoerceInputValue: per-param-type coercion (int/float/bool/string).
|
||||
// Accepts loosely-typed values (LLM-stringified numbers, JSON
|
||||
// float64s for ints) and returns a value in the target Go shape.
|
||||
//
|
||||
// - CoerceInputs: per-skill validation. Walks the InputSchema, coerces
|
||||
// each declared param via CoerceInputValue, drops extras silently,
|
||||
// errors on missing required.
|
||||
//
|
||||
// Why exported (capital): both consumers live in the same package, but
|
||||
// the names are also referenced in test files and the symbols are
|
||||
// genuinely useful API for any future consumer (webui form handler,
|
||||
// scheduler in v2). Keep the surface small.
|
||||
|
||||
// CoerceInputValue coerces a single raw value to the target InputParam
|
||||
// type. JSON numbers arrive from json.Unmarshal as float64; bools as
|
||||
// bool; strings as string. Type-mismatched strings are accepted ("3" →
|
||||
// int 3, "true" → bool true) because both LLM tool calls and Discord
|
||||
// command args frequently surface scalars as strings.
|
||||
//
|
||||
// Why: LLM tool-call args come through json.Unmarshal of a plain
|
||||
// map[string]any, which forces every JSON number into float64 and every
|
||||
// JSON string into string. Without this coerce step, an int parameter
|
||||
// would arrive in SkillInputs as a float64, a bool sent as "true" would
|
||||
// arrive as a string, etc. — confusing the skill agent's prompt
|
||||
// renderer and any tool-side logic that switches on Go type. The
|
||||
// .skill command handler benefits identically: arg tokens arrive as
|
||||
// strings, but downstream tools may expect typed values.
|
||||
//
|
||||
// Test: TestCoerceInputValue in inputs_test.go covers each branch.
|
||||
func CoerceInputValue(paramType string, v any) (any, error) {
|
||||
switch paramType {
|
||||
case "int":
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int(x), nil
|
||||
case int:
|
||||
return x, nil
|
||||
case string:
|
||||
var i int
|
||||
if _, err := fmt.Sscanf(x, "%d", &i); err != nil {
|
||||
return nil, fmt.Errorf("not an int: %q", x)
|
||||
}
|
||||
return i, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("not an int: %T", v)
|
||||
}
|
||||
case "float":
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, nil
|
||||
case int:
|
||||
return float64(x), nil
|
||||
case string:
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(x, "%f", &f); err != nil {
|
||||
return nil, fmt.Errorf("not a float: %q", x)
|
||||
}
|
||||
return f, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("not a float: %T", v)
|
||||
}
|
||||
case "bool":
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x, nil
|
||||
case string:
|
||||
switch x {
|
||||
case "true", "True", "TRUE", "1":
|
||||
return true, nil
|
||||
case "false", "False", "FALSE", "0":
|
||||
return false, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("not a bool: %q", x)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("not a bool: %T", v)
|
||||
}
|
||||
default:
|
||||
// "string", "user", "channel", "url", and unknown — coerce to
|
||||
// string. JSON numbers/bools are stringified so the executor's
|
||||
// validateInputs (which strips e.g. <@!123> wrappers) gets a
|
||||
// uniform string input.
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x, nil
|
||||
case float64:
|
||||
return fmt.Sprintf("%v", x), nil
|
||||
case bool:
|
||||
return fmt.Sprintf("%v", x), nil
|
||||
default:
|
||||
return fmt.Sprintf("%v", v), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CoerceInputs validates and coerces a map of raw caller-supplied values
|
||||
// against the declared parameter set:
|
||||
//
|
||||
// - Extra keys (not in params) are dropped silently.
|
||||
// - Missing required keys return an error so the caller can surface
|
||||
// usage information.
|
||||
// - Per-param type coercion handles int/float/bool sent as strings.
|
||||
//
|
||||
// Returns a fresh map containing only declared params; never mutates the
|
||||
// input map.
|
||||
//
|
||||
// Why: see CoerceInputValue. Both callers (chatbot exposure adapter,
|
||||
// .skill command handler) need the same required-check + extra-drop
|
||||
// semantics; previously only the chatbot path implemented them, which
|
||||
// is exactly why .skill <name> <args> dropped its arguments entirely.
|
||||
//
|
||||
// Test: TestCoerceInputs in inputs_test.go.
|
||||
func CoerceInputs(params []InputParam, raw map[string]any) (map[string]any, error) {
|
||||
out := make(map[string]any, len(params))
|
||||
for _, p := range params {
|
||||
v, present := raw[p.Name]
|
||||
if !present {
|
||||
if p.Required {
|
||||
return nil, fmt.Errorf("missing required parameter %q", p.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
typed, err := CoerceInputValue(p.Type, v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parameter %q: %w", p.Name, err)
|
||||
}
|
||||
out[p.Name] = typed
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ParseCommandInputs parses a free-form command argument string into a
|
||||
// raw map[string]any keyed by InputSchema parameter names. Three modes
|
||||
// are supported, picked by the shape of `schema`:
|
||||
//
|
||||
// CASE A — empty schema:
|
||||
// The whole string becomes {"request": "<rest>"}. Mirrors the
|
||||
// chatbot exposure default (DefaultChatbotInputName) so a skill with
|
||||
// no declared inputs can still receive its trigger text uniformly
|
||||
// across both surfaces.
|
||||
//
|
||||
// CASE B — exactly one required param (with optional non-required
|
||||
// tail):
|
||||
// If the user passed any --key=value or --key value flags they're
|
||||
// parsed as flags (Case C). Otherwise the WHOLE rest-of-message
|
||||
// becomes that single required param's value. This is the
|
||||
// "single-arg convenience" pattern that lets `.skill weather Boston
|
||||
// today` work without the user typing --city=.
|
||||
//
|
||||
// CASE C — multiple params, OR any --flag style input:
|
||||
// Tokens are parsed as `--name=value` or `--name value`. Bare
|
||||
// positional tokens after a flag are collected as that flag's value.
|
||||
// Trailing positional tokens with no preceding flag are dropped
|
||||
// (the caller's usage string should mention the flag form).
|
||||
//
|
||||
// The returned map values are RAW strings (or bool true for
|
||||
// presence-only flags); type coercion is the caller's job via
|
||||
// CoerceInputs.
|
||||
//
|
||||
// Why this signature instead of returning the typed map directly: the
|
||||
// caller wants to distinguish "missing required" (→ usage reply) from
|
||||
// "type coercion failed" (→ explicit error). Splitting parse from
|
||||
// coerce keeps the message specific.
|
||||
func ParseCommandInputs(schema []InputParam, raw string) map[string]any {
|
||||
out := map[string]any{}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return out
|
||||
}
|
||||
|
||||
// Detect flag-style input regardless of schema shape — even a single
|
||||
// required-param schema may be invoked via `.skill x --name value`
|
||||
// for forward compat.
|
||||
hasFlag := strings.Contains(raw, "--")
|
||||
|
||||
switch {
|
||||
case len(schema) == 0:
|
||||
// Empty schema: mirror the chatbot exposure adapter's default
|
||||
// "request" pseudo-param so executor.composePrompt can render
|
||||
// it uniformly.
|
||||
out[DefaultChatbotInputName] = raw
|
||||
|
||||
case !hasFlag && countRequired(schema) == 1:
|
||||
// Single-required-param convenience: whole rest-of-message is the
|
||||
// value, regardless of any other (non-required) params declared.
|
||||
// They can be supplied via --flag form if needed.
|
||||
req := firstRequired(schema)
|
||||
out[req.Name] = raw
|
||||
|
||||
default:
|
||||
// Flag-style parse. Walk tokens looking for --name[=value] or
|
||||
// --name <value>.
|
||||
parseFlagStyle(out, schema, raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// countRequired returns the number of params marked Required.
|
||||
func countRequired(schema []InputParam) int {
|
||||
n := 0
|
||||
for _, p := range schema {
|
||||
if p.Required {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// firstRequired returns the first required param. Caller must have
|
||||
// already verified at least one exists.
|
||||
func firstRequired(schema []InputParam) *InputParam {
|
||||
for i := range schema {
|
||||
if schema[i].Required {
|
||||
return &schema[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseFlagStyle walks tokens for --name=value and --name value forms.
|
||||
// Unknown flags (not in schema) are still accepted into the output map
|
||||
// so the caller can detect and warn about them; CoerceInputs will drop
|
||||
// extras when constructing the final SkillInputs.
|
||||
//
|
||||
// Tokens not preceded by a --flag are dropped. v1 is intentionally
|
||||
// strict-ish here: we don't try to guess which positional token belongs
|
||||
// to which param when there are several. The single-required-param
|
||||
// convenience handles the common ambiguity-free case in the caller.
|
||||
func parseFlagStyle(out map[string]any, schema []InputParam, raw string) {
|
||||
tokens := tokeniseCommandLine(raw)
|
||||
declared := map[string]bool{}
|
||||
for _, p := range schema {
|
||||
declared[p.Name] = true
|
||||
}
|
||||
|
||||
i := 0
|
||||
for i < len(tokens) {
|
||||
t := tokens[i]
|
||||
if !strings.HasPrefix(t, "--") {
|
||||
// Bare positional token outside a flag context — drop. The
|
||||
// caller's usage string should steer users to flag form.
|
||||
i++
|
||||
continue
|
||||
}
|
||||
key := t[2:]
|
||||
// --name=value form
|
||||
if eq := strings.IndexByte(key, '='); eq >= 0 {
|
||||
out[key[:eq]] = key[eq+1:]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// --name <value> form: take the next token IF it doesn't itself
|
||||
// start with --. Otherwise treat as a presence-only boolean flag.
|
||||
if i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "--") {
|
||||
out[key] = tokens[i+1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
out[key] = "true"
|
||||
i++
|
||||
}
|
||||
_ = declared // reserved for v2 unknown-flag warnings
|
||||
}
|
||||
|
||||
// tokeniseCommandLine splits a free-form Discord command argument
|
||||
// string into tokens. Quoted spans (single or double quotes) are kept
|
||||
// as one token so users can pass values with spaces:
|
||||
//
|
||||
// .skill weather --city="New York"
|
||||
// .skill summarise --text 'a long sentence here'
|
||||
//
|
||||
// Mirrors the user's intuition without introducing a full shell
|
||||
// parser. Newlines split as whitespace.
|
||||
func tokeniseCommandLine(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
var quote rune
|
||||
flush := func() {
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case quote != 0:
|
||||
if r == quote {
|
||||
quote = 0
|
||||
continue
|
||||
}
|
||||
cur.WriteRune(r)
|
||||
case r == '"' || r == '\'':
|
||||
quote = r
|
||||
case r == ' ' || r == '\t' || r == '\n':
|
||||
flush()
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveCommandInputs is the one-call helper a Discord .skill handler
|
||||
// uses to turn a free-form rest-of-message into a coerced
|
||||
// SkillInputs map ready to hand to the executor. It is the single
|
||||
// production entry point for command-side input resolution: every
|
||||
// caller must use it (do NOT chain ParseCommandInputs + CoerceInputs
|
||||
// directly).
|
||||
//
|
||||
// Why this exists as a single function: chaining
|
||||
// ParseCommandInputs + CoerceInputs at the call site is what broke
|
||||
// `.skill echo hello world` in production. ParseCommandInputs Case A
|
||||
// (empty schema) writes the user's text into out["request"], but
|
||||
// CoerceInputs(emptySchema, …) iterates the DECLARED params and
|
||||
// silently drops every key not in the schema — so "request" is
|
||||
// dropped before reaching the executor, and the agent's user-prompt
|
||||
// renders "(no input provided)". The fix is to mirror the chatbot
|
||||
// exposure adapter: derive the EFFECTIVE param set (which inflates
|
||||
// an empty schema to a single required "request" param) and coerce
|
||||
// against that, not the original empty schema.
|
||||
//
|
||||
// What:
|
||||
// - Empty input_schema → effective params = [{request, required, string}],
|
||||
// so ParseCommandInputs Case A's "request" key survives Coerce.
|
||||
// - Non-empty input_schema → effective params = the schema as-is, so
|
||||
// Case B / Case C parse-and-coerce semantics are unchanged.
|
||||
//
|
||||
// Returns the coerced SkillInputs map, or an error suitable for
|
||||
// surfacing to the user (e.g. via FormatUsage). Never mutates
|
||||
// `schema`.
|
||||
//
|
||||
// Test: TestResolveCommandInputs_* in inputs_test.go cover the three
|
||||
// cases plus the empty-schema regression.
|
||||
func ResolveCommandInputs(schema []InputParam, raw string) (map[string]any, error) {
|
||||
rawInputs := ParseCommandInputs(schema, raw)
|
||||
effective := effectiveCommandParams(schema)
|
||||
return CoerceInputs(effective, rawInputs)
|
||||
}
|
||||
|
||||
// effectiveCommandParams returns the parameter set the .skill command
|
||||
// path should use for coercion. Mirrors chatbotToolParams in
|
||||
// chatbot_provider.go: an empty input_schema is inflated to a single
|
||||
// required "request" string param so the user's free-text trigger
|
||||
// survives CoerceInputs's drop-extras semantics.
|
||||
//
|
||||
// Why a separate helper (vs reusing chatbotToolParams): keeping the
|
||||
// helper local to inputs.go avoids dragging chatbot_provider.go into
|
||||
// the .skill command path's import surface and makes the intent
|
||||
// (Discord-side parameter inflation) explicit at the call site.
|
||||
func effectiveCommandParams(schema []InputParam) []InputParam {
|
||||
if len(schema) > 0 {
|
||||
return schema
|
||||
}
|
||||
return []InputParam{{
|
||||
Name: DefaultChatbotInputName,
|
||||
Description: "The user's free-text trigger.",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
}}
|
||||
}
|
||||
|
||||
// FormatUsage renders a human-readable usage string for the .skill
|
||||
// invocation form. Used by command handlers when required params are
|
||||
// missing or coercion fails.
|
||||
//
|
||||
// Why: keep the usage message in one place so both the missing-required
|
||||
// and coercion-failed paths produce identical output.
|
||||
func FormatUsage(name string, schema []InputParam) string {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "usage: `.skill %s", name)
|
||||
if len(schema) == 0 {
|
||||
sb.WriteString(" <text>`")
|
||||
return sb.String()
|
||||
}
|
||||
if countRequired(schema) == 1 {
|
||||
req := firstRequired(schema)
|
||||
fmt.Fprintf(&sb, " <%s>`", req.Name)
|
||||
// Show optional flags (if any).
|
||||
var optional []InputParam
|
||||
for _, p := range schema {
|
||||
if !p.Required {
|
||||
optional = append(optional, p)
|
||||
}
|
||||
}
|
||||
if len(optional) > 0 {
|
||||
sb.WriteString("\n optional:")
|
||||
for _, p := range optional {
|
||||
fmt.Fprintf(&sb, " --%s=<%s>", p.Name, p.Type)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
// Multi-param: full --flag form.
|
||||
for _, p := range schema {
|
||||
if p.Required {
|
||||
fmt.Fprintf(&sb, " --%s=<%s>", p.Name, p.Type)
|
||||
}
|
||||
}
|
||||
for _, p := range schema {
|
||||
if !p.Required {
|
||||
fmt.Fprintf(&sb, " [--%s=<%s>]", p.Name, p.Type)
|
||||
}
|
||||
}
|
||||
sb.WriteString("`")
|
||||
return sb.String()
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Memory is a zero-dependency in-process SkillStore — a light host or test gets
|
||||
// saved-skill persistence with no DB. Mort backs SkillStore with GORM/MySQL;
|
||||
// contrib/store adds durable SQLite.
|
||||
type Memory struct {
|
||||
mu sync.RWMutex
|
||||
skills map[string]*Skill // by ID
|
||||
versions map[string][]SkillVersion // by skill ID, append order
|
||||
byVerID map[string]SkillVersion // by version ID
|
||||
}
|
||||
|
||||
// NewMemory returns an empty in-memory SkillStore.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{
|
||||
skills: map[string]*Skill{},
|
||||
versions: map[string][]SkillVersion{},
|
||||
byVerID: map[string]SkillVersion{},
|
||||
}
|
||||
}
|
||||
|
||||
var _ SkillStore = (*Memory)(nil)
|
||||
|
||||
func (m *Memory) Initialize(context.Context) error { return nil }
|
||||
|
||||
func (m *Memory) Save(_ context.Context, s *Skill) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := *s
|
||||
m.skills[s.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) Get(_ context.Context, id string) (*Skill, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
s, ok := m.skills[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
cp := *s
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetByName(_ context.Context, ownerID, name string) (*Skill, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, s := range m.skills {
|
||||
if s.OwnerID == ownerID && s.Name == name {
|
||||
cp := *s
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (m *Memory) Delete(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.skills, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) listWhere(keep func(*Skill) bool) []Skill {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]Skill, 0, len(m.skills))
|
||||
for _, s := range m.skills {
|
||||
if keep == nil || keep(s) {
|
||||
out = append(out, *s)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Memory) ListByOwner(_ context.Context, ownerID string) ([]Skill, error) {
|
||||
return m.listWhere(func(s *Skill) bool { return s.OwnerID == ownerID }), nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListPublic(context.Context) ([]Skill, error) {
|
||||
return m.listWhere(func(s *Skill) bool { return s.Visibility == VisibilityPublic }), nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListSharedWith(_ context.Context, memberID string) ([]Skill, error) {
|
||||
return m.listWhere(func(s *Skill) bool {
|
||||
if s.Visibility != VisibilityShared {
|
||||
return false
|
||||
}
|
||||
for _, id := range s.SharedWith {
|
||||
if id == memberID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListBuiltinByName(_ context.Context, name string) (*Skill, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, s := range m.skills {
|
||||
if s.Source == SourceBuiltin && s.Name == name {
|
||||
cp := *s
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (m *Memory) ListChatbotExposed(context.Context) ([]Skill, error) {
|
||||
return m.listWhere(func(s *Skill) bool { return s.ExposeAsChatbotTool }), nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListDueScheduled(_ context.Context, now time.Time) ([]Skill, error) {
|
||||
return m.listWhere(func(s *Skill) bool { return s.DueAt(now) }), nil
|
||||
}
|
||||
|
||||
func (m *Memory) MarkScheduledRun(_ context.Context, skillID string, ranAt, nextAt time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s, ok := m.skills[skillID]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
s.LastScheduledRunAt = ranAt
|
||||
s.NextRunAt = nextAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) AppendVersion(_ context.Context, sv SkillVersion) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.versions[sv.SkillID] = append(m.versions[sv.SkillID], sv)
|
||||
m.byVerID[sv.ID] = sv
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListVersionsBySkill(_ context.Context, skillID string, limit int) ([]SkillVersion, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
all := m.versions[skillID]
|
||||
// newest first
|
||||
out := make([]SkillVersion, 0, len(all))
|
||||
for i := len(all) - 1; i >= 0; i-- {
|
||||
out = append(out, all[i])
|
||||
if limit > 0 && len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetVersionByID(_ context.Context, versionID string) (*SkillVersion, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
sv, ok := m.byVerID[versionID]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &sv, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/run"
|
||||
)
|
||||
|
||||
// ToRunnable lowers a saved Skill into the kernel's run.RunnableAgent DTO, so
|
||||
// run.Executor can run a skill WITHOUT importing this battery (the inversion of
|
||||
// mort's skillexec running a skills.Skill). Maps the static shape only; the
|
||||
// skill's input schema → prompt rendering, palette resolution, audit, etc. are
|
||||
// supplied separately (the host renders inputs into the input string and wires
|
||||
// run.Ports). A skill exposes a flat tool list (no SkillPalette/SubAgentPalette
|
||||
// — composition is a host concern), so those stay empty.
|
||||
func (s *Skill) ToRunnable() run.RunnableAgent {
|
||||
return run.RunnableAgent{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
SystemPrompt: s.SystemPrompt,
|
||||
ModelTier: s.ModelTier,
|
||||
MaxIterations: s.MaxIterations,
|
||||
MaxRuntime: s.MaxRuntime,
|
||||
LowLevelTools: s.Tools,
|
||||
}
|
||||
}
|
||||
|
||||
// DueAt reports whether a scheduled skill is due at now (cron empty => never).
|
||||
// Convenience for a host scheduler that doesn't want to re-parse the cron.
|
||||
func (s *Skill) DueAt(now time.Time) bool {
|
||||
if s.Schedule == "" || s.NextRunAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return !s.NextRunAt.After(now)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// scheduleParser is the cron parser shared across the skills package. It
|
||||
// accepts the standard 5-field syntax (minute hour dom month dow) plus
|
||||
// descriptors such as @daily, @hourly, etc. We do not enable the seconds
|
||||
// field — schedule cadence is governed in minutes, and a seconds field
|
||||
// would invite specs that fire below the min-interval floor without
|
||||
// surfacing as such in the spec text.
|
||||
//
|
||||
// Why standalone vs. cron.ParseStandard: ParseStandard rejects descriptors
|
||||
// (@daily, @hourly). Skills callers may want to write @daily as a
|
||||
// shorthand alongside the explicit "daily" / "weekly" forms we translate
|
||||
// below.
|
||||
var scheduleParser = cron.NewParser(
|
||||
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
|
||||
)
|
||||
|
||||
// ParseSchedule turns a user-supplied schedule expression into a
|
||||
// cron.Schedule. The empty string returns (nil, nil) — callers should
|
||||
// treat that as "on-demand only".
|
||||
//
|
||||
// Why: Skill.Schedule is a string field stored verbatim; the validator,
|
||||
// the scheduler runner, and any future tooling all need to round-trip
|
||||
// through the same parser. Centralising it here avoids drift.
|
||||
//
|
||||
// Accepted shorthands:
|
||||
// - "daily" → "0 0 * * *" (midnight UTC every day)
|
||||
// - "weekly" → "0 0 * * 0" (midnight UTC every Sunday)
|
||||
//
|
||||
// Anything else is fed through robfig/cron/v3's standard parser
|
||||
// (descriptors enabled).
|
||||
//
|
||||
// Test: schedule_test.go covers shorthand expansion and invalid-spec
|
||||
// rejection.
|
||||
func ParseSchedule(expr string) (cron.Schedule, error) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
switch strings.ToLower(expr) {
|
||||
case "daily":
|
||||
expr = "0 0 * * *"
|
||||
case "weekly":
|
||||
expr = "0 0 * * 0"
|
||||
}
|
||||
sched, err := scheduleParser.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid schedule %q: %w", expr, err)
|
||||
}
|
||||
return sched, nil
|
||||
}
|
||||
|
||||
// ScheduleMinInterval returns an estimate of the smallest gap between
|
||||
// consecutive fire times for a parsed schedule. It samples the next two
|
||||
// fire times from a couple of starting points and returns the smallest
|
||||
// observed gap.
|
||||
//
|
||||
// Why: cron.Schedule does not expose a "smallest interval" API. The
|
||||
// validator needs this to enforce a per-skill min-interval floor (so an
|
||||
// admin can't accidentally register "* * * * *" and burn GPU minutes).
|
||||
// Two probe points are enough to catch irregular schedules whose tightest
|
||||
// gap appears at a particular point in the week (e.g. "0 9 * * 1,5",
|
||||
// where Mon→Fri is 4d but Fri→Mon is 3d — both sampled).
|
||||
//
|
||||
// Returns 0 if sched is nil.
|
||||
//
|
||||
// Test: schedule_test.go covers a "* * * * *" minute-interval probe and
|
||||
// the irregular Mon/Fri case.
|
||||
func ScheduleMinInterval(sched cron.Schedule) time.Duration {
|
||||
if sched == nil {
|
||||
return 0
|
||||
}
|
||||
// Probe from a fixed reference and from a midweek offset. Six fire
|
||||
// times across two starts catches weekly irregularities (the worst
|
||||
// case is a schedule that fires once a week — we still get one gap
|
||||
// per probe). Using a wall-clock-independent reference keeps the
|
||||
// test deterministic.
|
||||
starts := []time.Time{
|
||||
time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // Monday 00:00
|
||||
time.Date(2024, 1, 4, 12, 30, 0, 0, time.UTC), // Thursday 12:30
|
||||
time.Date(2024, 6, 15, 23, 59, 59, 0, time.UTC), // mid-year, late
|
||||
}
|
||||
var min time.Duration
|
||||
for _, t := range starts {
|
||||
// Sample three consecutive fires per start to capture two gaps.
|
||||
f1 := sched.Next(t)
|
||||
f2 := sched.Next(f1)
|
||||
f3 := sched.Next(f2)
|
||||
for _, gap := range []time.Duration{f2.Sub(f1), f3.Sub(f2)} {
|
||||
if gap <= 0 {
|
||||
continue
|
||||
}
|
||||
if min == 0 || gap < min {
|
||||
min = gap
|
||||
}
|
||||
}
|
||||
}
|
||||
return min
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
// Package skills implements the agentic skills platform: user-creatable
|
||||
// agent definitions (system prompt + tool whitelist + I/O spec) that run
|
||||
// in-process via majordomo's agent loop.
|
||||
//
|
||||
// A Skill is a saved agent definition. It can be invoked from Discord
|
||||
// (.skill <name>), exposed to the chatbot as a tool (via the
|
||||
// SkillsToolProvider), and (in v2) scheduled. Skills compose tools from
|
||||
// the skilltools registry, gated by a three-stage permission model:
|
||||
// save-time AuthoringRequirement, share-time SafeForShare, execute-time
|
||||
// SkillNameGate.
|
||||
//
|
||||
// This file declares the domain types only. Storage lives in storage.go;
|
||||
// validation lives in validate.go. The grand storage pattern documented in
|
||||
// pkg/logic/storage/CLAUDE.md applies — when adding a field to Skill, you
|
||||
// MUST also update pkg/logic/skills/gorm_model.go (gormSkill, fromStorage,
|
||||
// toStorage) or persistence will silently break.
|
||||
package skill
|
||||
|
||||
import "time"
|
||||
|
||||
// Skill is the domain definition of an agentic skill.
|
||||
//
|
||||
// Why: a skill is a saved agent definition reusable across invocations
|
||||
// (Discord, chatbot tool, scheduled run in v2). The struct is intentionally
|
||||
// flat — every field lives on its own column on the skills table; there is
|
||||
// no JSON-blob spec column. This keeps queries (e.g. "list all skills with
|
||||
// chatbot exposure") indexable and avoids opaque migration headaches.
|
||||
//
|
||||
// What: identity + authoring + agent spec + visibility + chatbot exposure
|
||||
// fields, all on one struct.
|
||||
//
|
||||
// Test: see validate_test.go and integration_test.go for round-trip and
|
||||
// validation coverage.
|
||||
type Skill struct {
|
||||
// Identity
|
||||
ID string // UUID
|
||||
OwnerID string // Discord member ID; empty for builtin
|
||||
Name string // unique per (owner, builtin namespace)
|
||||
Description string
|
||||
Source Source // SourceBuiltin | SourceManual
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
// Authoring (copied at save time from the user)
|
||||
AuthoredBy string // member ID at time of last edit (audit; may differ from owner over time)
|
||||
|
||||
// Versioning (for builtins; user skills typically stay at 1.0.0)
|
||||
Version string // semver; used by builtin loader to decide re-seed
|
||||
|
||||
// Spec — agent definition
|
||||
SystemPrompt string
|
||||
Tools []string // registry tool names
|
||||
ModelTier string // "fast" | "standard" | "thinking" | explicit "provider/model"
|
||||
InputSchema []InputParam
|
||||
OutputTarget OutputTarget
|
||||
Schedule string // cron; empty = on-demand only; rejected in v1 (ships in v2)
|
||||
Visibility Visibility // VisibilityPrivate | VisibilityShared | VisibilityPublic
|
||||
SharedWith []string // member IDs for visibility=shared
|
||||
MaxIterations int // 0 → use convar default
|
||||
MaxToolCalls int // 0 → use convar default
|
||||
MaxRuntime time.Duration // 0 → use convar default
|
||||
InitialMessage string
|
||||
|
||||
// Chatbot exposure (v1 — proves out the platform via mortventure)
|
||||
ExposeAsChatbotTool bool
|
||||
ChatbotToolName string
|
||||
ChatbotToolDescription string
|
||||
ChatbotChannelFilter string // named filter from the channel-filter registry
|
||||
|
||||
// Admin gating (v2 — public scheduled channel skills require approval).
|
||||
// DEPRECATED in v3: PinnedVersionID subsumes this flag for non-owner
|
||||
// invocation gating. CanInvoke no longer references this column.
|
||||
// Drop in v4.
|
||||
PendingApproval bool
|
||||
|
||||
// Pinned version (v3 — admin-curated invocation gate).
|
||||
//
|
||||
// Why: in v3, non-owner invocation requires that an admin explicitly
|
||||
// pin a known snapshot. This replaces v2's PendingApproval flag —
|
||||
// pinning is the explicit "approved for general use" signal, and the
|
||||
// pinned snapshot is what executes for non-owner callers (so an owner
|
||||
// editing a public skill never accidentally exposes work-in-progress
|
||||
// to other users).
|
||||
//
|
||||
// PinnedVersionID is the SkillVersion.ID (UUID) of the snapshot that
|
||||
// non-owner invocations resolve to. Empty means "no pin yet" — only
|
||||
// the owner and admins can invoke.
|
||||
//
|
||||
// Schema column is `pinned_version` per the design spec but the field
|
||||
// name in the domain struct is explicit about the kind of value it
|
||||
// holds (a snapshot row's UUID, NOT a semver string), which avoids
|
||||
// the spec ambiguity around "pin to v1.0.5" potentially mapping to
|
||||
// multiple snapshot rows over time.
|
||||
PinnedVersionID string
|
||||
|
||||
// PinnedAt is the wall-clock time the pin was set. Zero means
|
||||
// PinnedVersionID is empty (never pinned).
|
||||
PinnedAt time.Time
|
||||
|
||||
// PinnedBy is the admin member ID who set the current pin. Empty
|
||||
// when PinnedVersionID is empty.
|
||||
PinnedBy string
|
||||
|
||||
// Scheduler bookkeeping (v2). Updated by the scheduler runner after
|
||||
// a successful (or failed-but-counted) scheduled execution.
|
||||
//
|
||||
// LastScheduledRunAt records the wall-clock time of the most recent
|
||||
// scheduled invocation; zero means "never run on schedule".
|
||||
//
|
||||
// NextRunAt is the precomputed wake-up time the scheduler polls for
|
||||
// (`WHERE next_run_at <= NOW()`). It is recomputed by feeding
|
||||
// LastScheduledRunAt (or NOW() on first scheduling) through
|
||||
// ParseSchedule(Schedule).Next(...). Manual / on-demand invocations
|
||||
// MUST NOT touch these fields.
|
||||
LastScheduledRunAt time.Time
|
||||
NextRunAt time.Time
|
||||
|
||||
// ExtendedBounds, when true, lets a non-admin author save the skill
|
||||
// with bounds (MaxIterations / MaxToolCalls / MaxRuntime) above the
|
||||
// default tier (12/30/60s) up to the extended tier (50/150/600s).
|
||||
// Set by an admin via `.skill admin grant-extended <name>`. Cleared
|
||||
// by `.skill admin revoke-extended <name>`. Builtins and admin-
|
||||
// authored skills bypass the cap entirely (the tier resolution in
|
||||
// Validate treats AuthorIsAdmin and ExtendedBounds equivalently).
|
||||
//
|
||||
// Why a per-skill flag vs a per-user grant: governance is per-skill
|
||||
// — an admin reviews a specific skill's bounds and decides those
|
||||
// resource limits are justified for THAT skill. A user grant would
|
||||
// blanket-allow expensive bounds on every skill they author.
|
||||
ExtendedBounds bool
|
||||
|
||||
// ParallelCompositionAllowed gates whether this skill may use the
|
||||
// skill_invoke_parallel tool. Default false.
|
||||
//
|
||||
// Why a per-skill admin gate: parallel fan-out multiplies blast
|
||||
// radius (one bad skill spawns N concurrent runs). Admins approve
|
||||
// each skill that's allowed to use parallel composition; granting
|
||||
// is per-skill via `.skill admin grant-parallel <name>`. Builtins
|
||||
// may set this directly in skill.yml (the loader bypasses
|
||||
// save-time gates by design).
|
||||
//
|
||||
// Checked AT INVOCATION TIME (every skill_invoke_parallel call), so
|
||||
// admins can grant or revoke without redeploying. The check lives
|
||||
// in the tool handler (pkg/skilltools/tools/skill_invoke_parallel.go)
|
||||
// via the SkillInvokerProvider.IsParallelAllowed extension.
|
||||
ParallelCompositionAllowed bool
|
||||
|
||||
// ExecutionLane is the named lane the skill's runs are submitted to
|
||||
// when the executor routes through pkg/lane (v6). Default
|
||||
// "skill-default"; admin overrides per-skill via
|
||||
// `.skill admin set-lane <name> <lane>`.
|
||||
//
|
||||
// Why per-skill (vs a single global skill lane): different skills
|
||||
// have different concurrency profiles. A long-running web-research
|
||||
// skill might warrant a dedicated 1-slot lane to avoid starving
|
||||
// quick chatbot-exposed skills; an admin should be able to isolate
|
||||
// it without a code change.
|
||||
//
|
||||
// Empty string falls through to "skill-default" at executor time
|
||||
// — keeping the field nullable lets a future schema change
|
||||
// distinguish "explicit skill-default" from "never set".
|
||||
ExecutionLane string
|
||||
|
||||
// WebhookSecret enables inbound webhooks (v7). Empty = disabled
|
||||
// (the default). Non-empty = the random secret URL path segment
|
||||
// for POST /webhooks/<secret>. Generated by EnableWebhook;
|
||||
// rotated by RegenerateWebhookSecret. Storage is varchar(64) and
|
||||
// the secret is 32 random bytes (64 hex chars), so the column
|
||||
// holds a fully unique secret per skill.
|
||||
//
|
||||
// Why store the secret directly (not a hash): the webhook handler
|
||||
// must look up the skill by the secret on every POST, which would
|
||||
// require comparing every stored hash against the supplied secret
|
||||
// — a per-call O(n_skills) operation. The secret is treated as a
|
||||
// long random URL key (like a paste UUID); compromise is mitigated
|
||||
// via RegenerateWebhookSecret rotation, not via storage hashing.
|
||||
WebhookSecret string
|
||||
|
||||
// WebhookSignatureRequired controls whether the inbound webhook
|
||||
// handler verifies HMAC against the X-Mort-Signature header. Default
|
||||
// true (the storage column default). Toggling to false skips HMAC
|
||||
// verification — useful for low-stakes integrations behind an IP
|
||||
// allowlist where the caller can't easily compute HMAC. Owners
|
||||
// flip this on the management page; admins can also force it
|
||||
// back on if a leaked allowlist becomes a concern.
|
||||
WebhookSignatureRequired bool
|
||||
|
||||
// WebhookIPAllowlist is a newline-separated list of CIDR blocks
|
||||
// (or bare IPs). Empty string = no allowlist (accept any source
|
||||
// IP). The handler parses the list at request time so updates take
|
||||
// effect immediately without a redeploy. Invalid CIDR entries
|
||||
// are silently dropped at parse time (the management page form
|
||||
// shows a parse-error preview before save).
|
||||
WebhookIPAllowlist string
|
||||
|
||||
// EncryptionEnabled (v8) opts the skill into per-skill envelope
|
||||
// encryption for KV values and file blob content. Default false
|
||||
// (plaintext storage; matches the legacy default). When true, new
|
||||
// writes go through the AES-256-GCM helpers in pkg/skilltools and
|
||||
// the corresponding skill_kv / skill_file_blobs row stamps
|
||||
// encryption_key_version=1; reads transparently decrypt rows whose
|
||||
// version > 0 and pass through rows whose version == 0 (mixed
|
||||
// storage is supported indefinitely).
|
||||
//
|
||||
// !!!!! OPERATIONAL WARNING !!!!! This flag is a write-side switch
|
||||
// only. Disabling encryption for an already-encrypted skill does
|
||||
// NOT decrypt existing rows — they remain reachable as long as
|
||||
// the master key is intact. Losing SKILLS_ENCRYPTION_MASTER_KEY
|
||||
// renders every encrypted row unreadable; back the master key up
|
||||
// separately from database backups. See pkg/skilltools/encryption.go
|
||||
// for the full operational rules.
|
||||
EncryptionEnabled bool
|
||||
|
||||
// Preemptible (v9) opts the skill into preemption: when a higher-
|
||||
// priority job arrives at a full lane, this skill's running job may
|
||||
// be cancelled mid-flight to free a slot. Default false.
|
||||
//
|
||||
// !!!!! OPERATIONAL WARNING !!!!! Preemption means the skill's
|
||||
// scaddy.Agent context is cancelled mid-step; any partial side
|
||||
// effects (file writes, KV updates, sent emails, etc.) remain
|
||||
// committed. Only mark a skill preemptible when it is idempotent
|
||||
// or read-only — otherwise the user-visible state may be
|
||||
// inconsistent with the run's "preempted" terminal status.
|
||||
//
|
||||
// The lane scheduler will not preempt jobs younger than
|
||||
// `skills.lane.preemption_min_runtime_seconds` (default 30s) to
|
||||
// prevent thrashing. The preempted run is recorded with
|
||||
// status="preempted".
|
||||
Preemptible bool
|
||||
|
||||
// DefaultPriority (v9) is the per-skill default priority used by
|
||||
// the lane scheduler's fair-share queue ordering. Higher numbers
|
||||
// run first within a single user's sub-queue. Default 0.
|
||||
//
|
||||
// Per-invocation overrides (skill_invoke priority arg, webhook
|
||||
// X-Mort-Priority header) win over this default. Owners may set
|
||||
// values in the range [-`skills.priority_max_per_user`,
|
||||
// +`skills.priority_max_per_user`] (default cap 5); admins may
|
||||
// exceed the cap.
|
||||
DefaultPriority int
|
||||
|
||||
// Tags is a free-form set of short labels owners attach to a skill
|
||||
// for organisation + discovery. The list page renders each tag as a
|
||||
// chip and offers a dropdown filter populated from all visible
|
||||
// skills' tags.
|
||||
//
|
||||
// Why a separate field (vs reusing Description / Tools): tags are a
|
||||
// curatorial signal, not part of the agent spec — they only matter
|
||||
// to humans browsing the list. Storing them on the skill row (vs a
|
||||
// side table) keeps lookups index-only and matches how the rest of
|
||||
// the skill's flat fields are persisted.
|
||||
//
|
||||
// Validate enforces: each tag is trimmed + lowercased; max 32 chars
|
||||
// per tag; max 16 tags per skill; duplicates within a single skill
|
||||
// are deduped.
|
||||
Tags []string
|
||||
|
||||
// DeprecatedByAgentID is the Phase 7 soft-retire pointer: when
|
||||
// non-empty, the Skill is "soft retired" — hidden from default
|
||||
// listings (`.skill list`, the webui index, chatbot tool exposure)
|
||||
// but STILL invokable via `.skill <name>` and via `skill_invoke`
|
||||
// tool calls. The string is the agents.Agent.ID of the replacement
|
||||
// Agent that supersedes this Skill.
|
||||
//
|
||||
// Why a pointer (not a bool): a future audit / migration tool needs
|
||||
// to follow the soft-retire link back to the replacement. An admin
|
||||
// browsing the deprecated-skills page wants to see "what should I
|
||||
// use instead?" without a separate lookup table.
|
||||
//
|
||||
// Why keep the Skill row (not drop it): existing skill_invoke calls
|
||||
// in user-authored skills, scheduled jobs, and webhook integrations
|
||||
// would break if the row vanished. Soft-retire preserves the
|
||||
// callable surface while signalling "this is the old name; the
|
||||
// replacement Agent is the curated version."
|
||||
//
|
||||
// Set by the Phase 7 boot migration (pkg/logic/agents/migrate_phase7.go);
|
||||
// admins may also flip it manually via storage tooling. Listing
|
||||
// methods filter on this field by default but explicit GetByName /
|
||||
// GetForInvocation lookups bypass the filter so direct invocation
|
||||
// continues to work.
|
||||
DeprecatedByAgentID string
|
||||
|
||||
// DefaultEmoji is an optional identity emoji for the skill, shown
|
||||
// as the __start__ fallback when StateReactEmoji has no __start__
|
||||
// entry. Also forwarded to the invoking Discord message when a
|
||||
// parent agent calls this skill via skill_invoke, so the user sees
|
||||
// the child skill's identity emoji during execution.
|
||||
DefaultEmoji string
|
||||
|
||||
// StateReactEmoji maps tool names (and reserved keys "__start__",
|
||||
// "__end__", "__error__") to Discord emoji that the bot reacts to
|
||||
// the invoking message with as the skill progresses. Empty map
|
||||
// (the default) disables state-react reactions for this skill.
|
||||
//
|
||||
// Why: the legacy `.query` agent surfaced live progress via emoji
|
||||
// reactions on the invoking message (magnifying glass on search,
|
||||
// page on read, …). Skills inherit the same UX without each
|
||||
// author having to wire `update_status` for trivial signalling —
|
||||
// the emoji map is declarative and the executor calls inv.OnEvent
|
||||
// at the relevant boundaries. update_status remains for richer
|
||||
// interim text; emoji reactions are an additive lightweight signal.
|
||||
//
|
||||
// Reserved keys:
|
||||
// - __start__: reacted right before agent.Run starts
|
||||
// - __end__: reacted on successful completion
|
||||
// - __error__: reacted on terminal error
|
||||
//
|
||||
// Tool keys: react fires on each tool dispatch. Repeated reactions
|
||||
// of the same emoji are no-ops at Discord (idempotent), so a skill
|
||||
// that calls web_search 5x just leaves one 🔍.
|
||||
//
|
||||
// Map values are arbitrary Discord emoji strings (unicode emoji,
|
||||
// custom emoji `<:name:id>`, animated `<a:name:id>`). Validate does
|
||||
// not enforce a format — Discord rejects invalid emoji at react
|
||||
// time and the executor swallows that with a log line.
|
||||
StateReactEmoji map[string]string
|
||||
}
|
||||
|
||||
// ThreadIDInputKey is the magic key under skilltools.Invocation.SkillInputs
|
||||
// that the v2 .skill new / .skill edit wizard handlers use to thread a
|
||||
// pre-created thread channel ID through to delivery. When
|
||||
// OutputTarget.Kind == "thread" and this key is present in
|
||||
// inv.SkillInputs, delivery posts directly to that thread channel;
|
||||
// otherwise it falls back to OutputTarget.Target / inv.ChannelID.
|
||||
//
|
||||
// Why a magic input key vs an OutputTarget override field: keeps the
|
||||
// wire shape (Skill struct) unchanged and keeps the override scoped
|
||||
// to a single invocation. Wizard commands set this immediately after
|
||||
// MessageThreadStartComplex; nothing else writes it.
|
||||
//
|
||||
// Why defined here vs in skillexec: wizard command handlers in this
|
||||
// package need to write the key, and skillexec imports skills (so
|
||||
// the reverse import would cycle). Skillexec aliases this constant.
|
||||
const ThreadIDInputKey = "__thread_id__"
|
||||
|
||||
// Source distinguishes builtins (loaded from skills/<name>/skill.yml on
|
||||
// boot) from user-authored manual skills.
|
||||
//
|
||||
// Why: builtin skills bypass save-time authoring and share-time safety
|
||||
// checks because the loader is trusted infrastructure.
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceBuiltin Source = "builtin"
|
||||
SourceManual Source = "manual"
|
||||
)
|
||||
|
||||
// InputParam declares a typed input slot on a skill, populated at
|
||||
// invocation time from positional/flag args (Discord) or form fields
|
||||
// (webui).
|
||||
//
|
||||
// Why: skills are invoked from heterogeneous surfaces and need a uniform
|
||||
// schema for input collection and validation. The Type drives string→typed
|
||||
// coercion in skillexec.validateInputs; Choices restricts to an enum set.
|
||||
type InputParam struct {
|
||||
Name string
|
||||
Description string
|
||||
Type string // "string"|"int"|"float"|"bool"|"user"|"channel"|"url"
|
||||
Required bool
|
||||
Default string // string-encoded; parsed per Type at invocation
|
||||
Choices []string
|
||||
}
|
||||
|
||||
// OutputTarget controls where the executor delivers a skill's output.
|
||||
//
|
||||
// Why: skills run in many contexts and the user shouldn't have to think
|
||||
// about delivery — the spec encodes it once. The Discord delivery
|
||||
// implementation in pkg/logic/skillexec/delivery.go reads this struct.
|
||||
type OutputTarget struct {
|
||||
Kind string // "channel"|"dm"|"thread"|"webui_only"|"channel_with_summary"
|
||||
Target string // channel/member/thread ID, or empty for caller-context
|
||||
}
|
||||
|
||||
// Visibility controls who may invoke a skill.
|
||||
//
|
||||
// Why: separates *invocation* gating (this struct) from *tool authoring*
|
||||
// gating (skilltools.Permission) — they are orthogonal. A non-admin can
|
||||
// invoke an admin-authored public skill that uses db_select; the permission
|
||||
// model for the underlying tool only fires at save time, not invocation.
|
||||
type Visibility string
|
||||
|
||||
const (
|
||||
VisibilityPrivate Visibility = "private"
|
||||
VisibilityShared Visibility = "shared"
|
||||
VisibilityPublic Visibility = "public"
|
||||
)
|
||||
|
||||
// IsKnownVisibility reports whether v is a recognised visibility value.
|
||||
// Used by Validate.
|
||||
func IsKnownVisibility(v Visibility) bool {
|
||||
switch v {
|
||||
case VisibilityPrivate, VisibilityShared, VisibilityPublic:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsKnownOutputKind reports whether kind is a recognised OutputTarget.Kind.
|
||||
// Used by Validate and by the Discord delivery switch.
|
||||
//
|
||||
// "channel_with_summary" is the v-research delivery kind: full output
|
||||
// posts to a configured spam channel (skills.research.spam_channel_id)
|
||||
// while a generated summary posts in the original channel as a reply
|
||||
// linking back. Falls through to plain "channel" behaviour when the
|
||||
// spam channel convar is unset or matches the invocation channel.
|
||||
// Validate accepts this kind here; the Discord delivery switch in
|
||||
// pkg/logic/skillexec/delivery_discord.go is the consumer side.
|
||||
func IsKnownOutputKind(kind string) bool {
|
||||
switch kind {
|
||||
case "channel", "dm", "thread", "webui_only", "channel_with_summary":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsKnownInputType reports whether t is a recognised InputParam.Type.
|
||||
// Used by Validate and by skillexec.validateInputs for coercion dispatch.
|
||||
func IsKnownInputType(t string) bool {
|
||||
switch t {
|
||||
case "string", "int", "float", "bool", "user", "channel", "url":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSkillToRunnable(t *testing.T) {
|
||||
s := &Skill{
|
||||
ID: "s1", Name: "summarizer", SystemPrompt: "summarize well", ModelTier: "fast",
|
||||
MaxIterations: 4, MaxRuntime: 20 * time.Second, Tools: []string{"summarize", "now"},
|
||||
}
|
||||
r := s.ToRunnable()
|
||||
if r.ID != "s1" || r.ModelTier != "fast" || r.MaxIterations != 4 || len(r.LowLevelTools) != 2 {
|
||||
t.Fatalf("ToRunnable mapping wrong: %+v", r)
|
||||
}
|
||||
// A skill exposes a flat tool list, not a palette.
|
||||
if len(r.SkillPalette) != 0 || len(r.SubAgentPalette) != 0 {
|
||||
t.Errorf("skill should have empty palettes, got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreVisibilityAndVersions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := NewMemory()
|
||||
pub := &Skill{ID: "a", Name: "pub", OwnerID: "o1", Visibility: VisibilityPublic}
|
||||
shared := &Skill{ID: "b", Name: "shr", OwnerID: "o1", Visibility: VisibilityShared, SharedWith: []string{"bob"}}
|
||||
priv := &Skill{ID: "c", Name: "prv", OwnerID: "o1", Visibility: VisibilityPrivate}
|
||||
for _, s := range []*Skill{pub, shared, priv} {
|
||||
if err := m.Save(ctx, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if ps, _ := m.ListPublic(ctx); len(ps) != 1 || ps[0].ID != "a" {
|
||||
t.Errorf("ListPublic = %+v", ps)
|
||||
}
|
||||
if ss, _ := m.ListSharedWith(ctx, "bob"); len(ss) != 1 || ss[0].ID != "b" {
|
||||
t.Errorf("ListSharedWith(bob) = %+v", ss)
|
||||
}
|
||||
if ss, _ := m.ListSharedWith(ctx, "carol"); len(ss) != 0 {
|
||||
t.Errorf("ListSharedWith(carol) should be empty, got %+v", ss)
|
||||
}
|
||||
if all, _ := m.ListByOwner(ctx, "o1"); len(all) != 3 {
|
||||
t.Errorf("ListByOwner = %d, want 3", len(all))
|
||||
}
|
||||
// Versions: newest-first, fetchable by id.
|
||||
m.AppendVersion(ctx, SkillVersion{ID: "v1", SkillID: "a", Version: "1.0.0"})
|
||||
m.AppendVersion(ctx, SkillVersion{ID: "v2", SkillID: "a", Version: "1.1.0"})
|
||||
vs, _ := m.ListVersionsBySkill(ctx, "a", 10)
|
||||
if len(vs) != 2 || vs[0].ID != "v2" {
|
||||
t.Errorf("versions newest-first wrong: %+v", vs)
|
||||
}
|
||||
if got, err := m.GetVersionByID(ctx, "v1"); err != nil || got.Version != "1.0.0" {
|
||||
t.Errorf("GetVersionByID: %v %+v", err, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package skill
|
||||
|
||||
import "time"
|
||||
|
||||
// SkillVersion is one immutable snapshot of a Skill at the moment it
|
||||
// was saved. The skill_versions table is append-only; pruning is by
|
||||
// retention policy in PruneOldVersions.
|
||||
//
|
||||
// Why: edit history with rollback (v3) and the admin pin gate (v3 Phase 4)
|
||||
// both need a stable snapshot of the skill at a known version. The Snapshot
|
||||
// field carries the FULL Skill struct so a later restore or pin produces
|
||||
// the exact agent definition that was saved — system_prompt, tools,
|
||||
// schedule, every field — not a synthesized partial snapshot.
|
||||
//
|
||||
// What: identity (UUID per snapshot) + skill ref + version-string copy +
|
||||
// the full Skill payload + audit fields (saved_by, saved_at, edit_summary).
|
||||
//
|
||||
// Test: see skill_version_test.go for round-trip, list ordering, prune
|
||||
// retention, and version-by-number disambiguation coverage.
|
||||
type SkillVersion struct {
|
||||
ID string // UUID per snapshot (NOT the skill's ID)
|
||||
SkillID string // FK to skills.id (conceptually; not enforced by GORM)
|
||||
Version string // Skill.Version at save time (semver)
|
||||
Snapshot Skill // full Skill struct embedded; serialised as JSON
|
||||
SavedBy string // caller member ID (or "" for builtin loader / pre-v3)
|
||||
SavedAt time.Time // wall-clock save time
|
||||
EditSummary string // optional human-readable note ("changed model tier", "...")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a skill (or version) lookup misses.
|
||||
var ErrNotFound = errors.New("skill not found")
|
||||
|
||||
// SkillStore is the persistence seam for saved skills. This is the DELIBERATELY
|
||||
// LEAN redesign of mort's 60-method skills.Storage: it carries only skill
|
||||
// lifecycle (CRUD + visibility), versioning, and scheduling. The KV/file/quota
|
||||
// sub-stores that were fused into mort's interface are NOT here — they are the
|
||||
// tools/ store seams (KVStorage / FileStorage / QuotaProvider); email recipients
|
||||
// and channel grants stay host concerns. A host backs this with its DB; Memory()
|
||||
// is the zero-dependency default; contrib/store adds durable SQLite.
|
||||
type SkillStore interface {
|
||||
// Initialize prepares storage (idempotent).
|
||||
Initialize(ctx context.Context) error
|
||||
|
||||
// --- lifecycle ---
|
||||
Save(ctx context.Context, s *Skill) error
|
||||
Get(ctx context.Context, id string) (*Skill, error)
|
||||
GetByName(ctx context.Context, ownerID, name string) (*Skill, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// --- listing / visibility ---
|
||||
ListByOwner(ctx context.Context, ownerID string) ([]Skill, error)
|
||||
ListPublic(ctx context.Context) ([]Skill, error)
|
||||
ListSharedWith(ctx context.Context, memberID string) ([]Skill, error)
|
||||
ListBuiltinByName(ctx context.Context, name string) (*Skill, error)
|
||||
ListChatbotExposed(ctx context.Context) ([]Skill, error)
|
||||
|
||||
// --- scheduling ---
|
||||
ListDueScheduled(ctx context.Context, now time.Time) ([]Skill, error)
|
||||
MarkScheduledRun(ctx context.Context, skillID string, ranAt, nextAt time.Time) error
|
||||
|
||||
// --- versioning ---
|
||||
AppendVersion(ctx context.Context, sv SkillVersion) error
|
||||
ListVersionsBySkill(ctx context.Context, skillID string, limit int) ([]SkillVersion, error)
|
||||
GetVersionByID(ctx context.Context, versionID string) (*SkillVersion, error)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/executus/model"
|
||||
)
|
||||
|
||||
// ChannelFilterChecker is the subset of ChannelFilterRegistry used by
|
||||
// Validate to check that a skill references a registered channel filter.
|
||||
//
|
||||
// Why: kept narrow so tests can pass a tiny stub; full registry is
|
||||
// declared in channel_filters.go.
|
||||
type ChannelFilterChecker interface {
|
||||
Has(name string) bool
|
||||
}
|
||||
|
||||
// ModelTierChecker reports whether the given model tier or
|
||||
// "provider/model" spec is recognised. Validate uses this to reject
|
||||
// typos at save time.
|
||||
//
|
||||
// Why: tiers come from llms.tier.* convars (fast/standard/thinking by
|
||||
// default) but admins may add custom tiers; explicit "provider/model"
|
||||
// is also valid. Validate accepts anything non-empty matching either
|
||||
// pattern — finer correctness is the LLM call's job.
|
||||
type ModelTierChecker interface {
|
||||
IsValid(spec string) bool
|
||||
}
|
||||
|
||||
// defaultModelTierChecker accepts all registered tier names (via
|
||||
// model.IsTierName) plus any "provider/model" form (string contains "/").
|
||||
// Tests can substitute a strict checker via ValidateOpts.ModelTierChecker.
|
||||
type defaultModelTierChecker struct{}
|
||||
|
||||
func (defaultModelTierChecker) IsValid(spec string) bool {
|
||||
if spec == "" {
|
||||
return false
|
||||
}
|
||||
if model.IsTierName(spec) {
|
||||
return true
|
||||
}
|
||||
// Accept tier-with-reasoning (e.g. "thinking:high")
|
||||
if i := strings.IndexByte(spec, ':'); i > 0 {
|
||||
if model.IsTierName(spec[:i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Accept explicit "provider/model" or "provider/model:reasoning"
|
||||
return strings.ContainsRune(spec, '/')
|
||||
}
|
||||
|
||||
// ValidateOpts customises what Validate accepts. All fields are optional;
|
||||
// nil checkers fall back to permissive defaults.
|
||||
//
|
||||
// Why: Validate is called from save paths (which know the registries) and
|
||||
// from tests (which want to control acceptance). Bundling the deps here
|
||||
// keeps the Skill API stable.
|
||||
type ValidateOpts struct {
|
||||
// Filters is consulted when the skill declares a chatbot channel
|
||||
// filter. nil → channel-filter validity is not checked (use only in
|
||||
// tests).
|
||||
Filters ChannelFilterChecker
|
||||
// ModelTier checks the ModelTier spec. nil → defaultModelTierChecker.
|
||||
ModelTier ModelTierChecker
|
||||
// MinIntervalMinutes is the floor on the smallest gap between
|
||||
// consecutive fires of a skill's cron schedule. Zero → use the
|
||||
// package default (defaultMinScheduleIntervalMinutes). Tests pass an
|
||||
// explicit value to exercise the boundary.
|
||||
MinIntervalMinutes int
|
||||
|
||||
// AuthorIsAdmin tells Validate the author has admin privileges and
|
||||
// may save with extended-tier bounds without ExtendedBounds=true.
|
||||
// SaveUserSkill passes this from s.admin.IsAdmin(sk.AuthoredBy).
|
||||
// Builtin loader sets this true to bypass the per-skill flag check
|
||||
// (builtins are trusted infrastructure).
|
||||
AuthorIsAdmin bool
|
||||
|
||||
// DefaultMaxIterations / DefaultMaxToolCalls / DefaultMaxRuntimeSecs
|
||||
// override the package-default tier-1 caps. Zero → fall back to the
|
||||
// constants below. Production wiring populates these from convars
|
||||
// (skills.default_max_iterations etc.) so admins can adjust the
|
||||
// default tier without a redeploy.
|
||||
DefaultMaxIterations int
|
||||
DefaultMaxToolCalls int
|
||||
DefaultMaxRuntimeSecs int
|
||||
|
||||
// ExtendedMaxIterations / ExtendedMaxToolCalls / ExtendedMaxRuntimeSecs
|
||||
// override the package-default tier-2 caps (the ceilings allowed when
|
||||
// ExtendedBounds=true OR AuthorIsAdmin=true). Zero → fall back to the
|
||||
// constants below.
|
||||
ExtendedMaxIterations int
|
||||
ExtendedMaxToolCalls int
|
||||
ExtendedMaxRuntimeSecs int
|
||||
}
|
||||
|
||||
// Tiered cap defaults. The DEFAULT tier is what a non-admin author sees
|
||||
// without an explicit grant; the EXTENDED tier is what admin authors and
|
||||
// admin-granted skills may use. Values are tuned in the v3 spec
|
||||
// "Governance: tiered resource caps" section.
|
||||
//
|
||||
// The package's existing absolute ceilings (maxIterationsLimit=50 and
|
||||
// maxRuntime=10m) act as outer floors / sanity bounds; the tier caps
|
||||
// are the active gate at save time. Extended caps respect the absolute
|
||||
// ceilings naturally (50 iter, 600s = 10min runtime).
|
||||
const (
|
||||
// Default tier — non-admin authors of skills without ExtendedBounds.
|
||||
DefaultMaxIterations = 12
|
||||
DefaultMaxToolCalls = 30
|
||||
DefaultMaxRuntimeSecs = 60
|
||||
|
||||
// Extended tier — admin authors OR ExtendedBounds=true.
|
||||
ExtendedMaxIterations = 50
|
||||
ExtendedMaxToolCalls = 150
|
||||
ExtendedMaxRuntimeSecs = 600 // 10m
|
||||
|
||||
maxIterationsLimit = 50
|
||||
minRuntime = time.Second
|
||||
maxRuntime = 10 * time.Minute
|
||||
defaultMinScheduleIntervalMinutes = 30
|
||||
|
||||
// MaxTagsPerSkill caps the number of organisation tags any single
|
||||
// skill may carry. Generous compared to typical taxonomies (GitHub
|
||||
// allows ~10 topics/repo). The cap exists to prevent the list
|
||||
// page's chip rendering from becoming unmanageable.
|
||||
MaxTagsPerSkill = 16
|
||||
|
||||
// MaxTagLength is the per-tag character ceiling. Long enough for
|
||||
// hyphenated phrases ("retro-gaming") but short enough that the
|
||||
// list-page tag dropdown stays readable.
|
||||
MaxTagLength = 32
|
||||
)
|
||||
|
||||
// Validate enforces the skill spec invariants documented in the design
|
||||
// spec ("Skill domain model" section). It is called at save time; the
|
||||
// builtin loader skips authoring/share-safety checks but still runs
|
||||
// Validate, so all callers can rely on a saved skill being well-formed.
|
||||
//
|
||||
// Why: spec rules are easy to violate by hand and silently break
|
||||
// downstream (e.g. an unknown channel filter never exposes the skill to
|
||||
// the chatbot). Every rule fails loudly here.
|
||||
//
|
||||
// What: returns the first error found; callers may surface it directly to
|
||||
// users. opts may be the zero value, in which case channel-filter
|
||||
// validation is skipped (tests).
|
||||
//
|
||||
// Test: each rejection branch has a dedicated unit test in
|
||||
// validate_test.go.
|
||||
func (s *Skill) Validate(opts ValidateOpts) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("skill is nil")
|
||||
}
|
||||
if strings.TrimSpace(s.Name) == "" {
|
||||
return fmt.Errorf("skill name is required")
|
||||
}
|
||||
if strings.TrimSpace(s.SystemPrompt) == "" {
|
||||
return fmt.Errorf("skill system prompt is required")
|
||||
}
|
||||
|
||||
// ModelTier
|
||||
tierCheck := opts.ModelTier
|
||||
if tierCheck == nil {
|
||||
tierCheck = defaultModelTierChecker{}
|
||||
}
|
||||
if !tierCheck.IsValid(s.ModelTier) {
|
||||
return fmt.Errorf("unknown model tier %q (expected a tier alias or provider/model)", s.ModelTier)
|
||||
}
|
||||
|
||||
// Schedule — empty means on-demand only. A non-empty value must be
|
||||
// a valid cron expression (or one of the "daily" / "weekly"
|
||||
// shorthands) AND have a smallest fire-gap >= the configured
|
||||
// min-interval floor. Both checks share the package-level
|
||||
// ParseSchedule helper so the scheduler runner uses the same parser.
|
||||
if expr := strings.TrimSpace(s.Schedule); expr != "" {
|
||||
sched, err := ParseSchedule(expr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule: %w", err)
|
||||
}
|
||||
minMinutes := opts.MinIntervalMinutes
|
||||
if minMinutes == 0 {
|
||||
minMinutes = defaultMinScheduleIntervalMinutes
|
||||
}
|
||||
floor := time.Duration(minMinutes) * time.Minute
|
||||
if interval := ScheduleMinInterval(sched); interval < floor {
|
||||
return fmt.Errorf(
|
||||
"schedule %q runs more often than the minimum (every %s, floor is %s)",
|
||||
expr, interval.Round(time.Second), floor)
|
||||
}
|
||||
}
|
||||
|
||||
// Iteration / call / runtime budgets. Zero is allowed — the executor
|
||||
// substitutes a convar-backed default. Negative is always wrong.
|
||||
// The absolute ceilings (maxIterationsLimit=50, maxRuntime=10m) are
|
||||
// outer sanity bounds; the tier caps below are the active gate.
|
||||
//
|
||||
// Why admin bypass on the outer ceilings: builtins are trusted
|
||||
// infrastructure (per the v2 "Builtin loader must bypass save-time
|
||||
// gates" lesson). The builtin loader passes AuthorIsAdmin=true so
|
||||
// trusted skills like `deepresearch` (max_iterations=100,
|
||||
// max_runtime=45m) and `research` (max_runtime=15m) can validate
|
||||
// without re-tuning the package-wide outer floor for everyone.
|
||||
// Non-admin authors still hit the original ceilings AND the
|
||||
// tier-based cap (default 12 iter / 60s runtime, extended 50 iter /
|
||||
// 600s runtime) — both layers stay intact for the untrusted path.
|
||||
if s.MaxIterations < 0 {
|
||||
return fmt.Errorf("max_iterations must be >= 0, got %d", s.MaxIterations)
|
||||
}
|
||||
if !opts.AuthorIsAdmin && s.MaxIterations > maxIterationsLimit {
|
||||
return fmt.Errorf("max_iterations must be 0..%d, got %d", maxIterationsLimit, s.MaxIterations)
|
||||
}
|
||||
if s.MaxToolCalls < 0 {
|
||||
return fmt.Errorf("max_tool_calls must be >= 0, got %d", s.MaxToolCalls)
|
||||
}
|
||||
if s.MaxRuntime < 0 {
|
||||
return fmt.Errorf("max_runtime must be 0 or positive, got %s", s.MaxRuntime)
|
||||
}
|
||||
if s.MaxRuntime > 0 && s.MaxRuntime < minRuntime {
|
||||
return fmt.Errorf("max_runtime must be 0 or >= %s, got %s", minRuntime, s.MaxRuntime)
|
||||
}
|
||||
if !opts.AuthorIsAdmin && s.MaxRuntime > maxRuntime {
|
||||
return fmt.Errorf("max_runtime must be 0 or in [%s..%s], got %s", minRuntime, maxRuntime, s.MaxRuntime)
|
||||
}
|
||||
|
||||
// Tiered cap resolution: a skill saved by an admin OR a skill with
|
||||
// ExtendedBounds=true (admin-granted) may use the extended tier;
|
||||
// everything else saturates at the default tier. Builtins go through
|
||||
// the loader's bypass path (AuthorIsAdmin=true).
|
||||
defIter := opts.DefaultMaxIterations
|
||||
if defIter == 0 {
|
||||
defIter = DefaultMaxIterations
|
||||
}
|
||||
defCalls := opts.DefaultMaxToolCalls
|
||||
if defCalls == 0 {
|
||||
defCalls = DefaultMaxToolCalls
|
||||
}
|
||||
defRuntime := opts.DefaultMaxRuntimeSecs
|
||||
if defRuntime == 0 {
|
||||
defRuntime = DefaultMaxRuntimeSecs
|
||||
}
|
||||
extIter := opts.ExtendedMaxIterations
|
||||
if extIter == 0 {
|
||||
extIter = ExtendedMaxIterations
|
||||
}
|
||||
extCalls := opts.ExtendedMaxToolCalls
|
||||
if extCalls == 0 {
|
||||
extCalls = ExtendedMaxToolCalls
|
||||
}
|
||||
extRuntime := opts.ExtendedMaxRuntimeSecs
|
||||
if extRuntime == 0 {
|
||||
extRuntime = ExtendedMaxRuntimeSecs
|
||||
}
|
||||
maxIter := defIter
|
||||
maxCalls := defCalls
|
||||
maxRuntimeSecs := defRuntime
|
||||
tier := "default"
|
||||
hint := "; ask an admin to grant extended_bounds for higher"
|
||||
if s.ExtendedBounds || opts.AuthorIsAdmin {
|
||||
maxIter = extIter
|
||||
maxCalls = extCalls
|
||||
maxRuntimeSecs = extRuntime
|
||||
tier = "extended"
|
||||
hint = "" // already at the highest tier — no upgrade path
|
||||
}
|
||||
// Admin bypass on the tier cap: trusted infrastructure (builtins,
|
||||
// admin-authored skills) may exceed the extended tier. The
|
||||
// non-admin author still hits the tier cap above. See the
|
||||
// "trusted infrastructure" rationale on the outer-ceiling block.
|
||||
if !opts.AuthorIsAdmin {
|
||||
if s.MaxIterations > maxIter {
|
||||
return fmt.Errorf("max_iterations %d exceeds %s cap (%d)%s",
|
||||
s.MaxIterations, tier, maxIter, hint)
|
||||
}
|
||||
if s.MaxToolCalls > maxCalls {
|
||||
return fmt.Errorf("max_tool_calls %d exceeds %s cap (%d)%s",
|
||||
s.MaxToolCalls, tier, maxCalls, hint)
|
||||
}
|
||||
if s.MaxRuntime > 0 && s.MaxRuntime > time.Duration(maxRuntimeSecs)*time.Second {
|
||||
return fmt.Errorf("max_runtime %s exceeds %s cap (%ds)%s",
|
||||
s.MaxRuntime, tier, maxRuntimeSecs, hint)
|
||||
}
|
||||
}
|
||||
|
||||
// Output target
|
||||
if !IsKnownOutputKind(s.OutputTarget.Kind) {
|
||||
return fmt.Errorf("unknown output_target.kind %q", s.OutputTarget.Kind)
|
||||
}
|
||||
|
||||
// Input schema
|
||||
seenInput := map[string]struct{}{}
|
||||
for i, p := range s.InputSchema {
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return fmt.Errorf("input_schema[%d]: Name is required", i)
|
||||
}
|
||||
if !IsKnownInputType(p.Type) {
|
||||
return fmt.Errorf("input_schema[%d] (%q): unknown type %q", i, p.Name, p.Type)
|
||||
}
|
||||
if _, dup := seenInput[p.Name]; dup {
|
||||
return fmt.Errorf("input_schema: duplicate parameter name %q", p.Name)
|
||||
}
|
||||
seenInput[p.Name] = struct{}{}
|
||||
}
|
||||
|
||||
// Tools
|
||||
seenTool := map[string]struct{}{}
|
||||
for _, t := range s.Tools {
|
||||
if strings.TrimSpace(t) == "" {
|
||||
return fmt.Errorf("tools: empty tool name")
|
||||
}
|
||||
if _, dup := seenTool[t]; dup {
|
||||
return fmt.Errorf("tools: duplicate tool name %q", t)
|
||||
}
|
||||
seenTool[t] = struct{}{}
|
||||
}
|
||||
|
||||
// Tags — normalise + bounds-check. The caller may pass user input
|
||||
// directly; we trim, lowercase, dedup, and bound count + per-tag
|
||||
// length. Mutating the slice in place is intentional so callers
|
||||
// don't need a separate normalise pass.
|
||||
//
|
||||
// Why caps (16 tags / 32 chars): both are generous for human-
|
||||
// curated organisation labels (compare to GitHub's 10 topics/repo
|
||||
// + ~50 chars). The aim is rejecting accidental data dumps and
|
||||
// keeping the list-page chip rendering manageable, not strict
|
||||
// taxonomy enforcement.
|
||||
if len(s.Tags) > MaxTagsPerSkill {
|
||||
return fmt.Errorf("tags: too many (max %d, got %d)", MaxTagsPerSkill, len(s.Tags))
|
||||
}
|
||||
if len(s.Tags) > 0 {
|
||||
seenTag := map[string]struct{}{}
|
||||
out := make([]string, 0, len(s.Tags))
|
||||
for _, raw := range s.Tags {
|
||||
t := strings.ToLower(strings.TrimSpace(raw))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if len(t) > MaxTagLength {
|
||||
return fmt.Errorf("tags: %q exceeds %d chars", t, MaxTagLength)
|
||||
}
|
||||
if _, dup := seenTag[t]; dup {
|
||||
continue
|
||||
}
|
||||
seenTag[t] = struct{}{}
|
||||
out = append(out, t)
|
||||
}
|
||||
s.Tags = out
|
||||
}
|
||||
|
||||
// Visibility
|
||||
if !IsKnownVisibility(s.Visibility) {
|
||||
return fmt.Errorf("unknown visibility %q", s.Visibility)
|
||||
}
|
||||
if s.Visibility == VisibilityShared && len(s.SharedWith) == 0 {
|
||||
return fmt.Errorf("visibility=shared requires non-empty shared_with")
|
||||
}
|
||||
|
||||
// Chatbot exposure
|
||||
if s.ExposeAsChatbotTool {
|
||||
if strings.TrimSpace(s.ChatbotToolName) == "" {
|
||||
return fmt.Errorf("expose_as_chatbot_tool=true requires chatbot_tool_name")
|
||||
}
|
||||
if strings.TrimSpace(s.ChatbotToolDescription) == "" {
|
||||
return fmt.Errorf("expose_as_chatbot_tool=true requires chatbot_tool_description")
|
||||
}
|
||||
if strings.TrimSpace(s.ChatbotChannelFilter) == "" {
|
||||
return fmt.Errorf("expose_as_chatbot_tool=true requires chatbot_channel_filter")
|
||||
}
|
||||
if opts.Filters != nil && !opts.Filters.Has(s.ChatbotChannelFilter) {
|
||||
return fmt.Errorf("unknown chatbot_channel_filter %q (not registered)", s.ChatbotChannelFilter)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user