Files
executus/skill/skill.go
T
steve 41659b2412 P4: skill noun — domain + LEAN SkillStore + ToRunnable + Memory
The skill half of the persona/skill pair, as a clean redesign (not a faithful
lift of mort's 60-method skills.Storage kitchen sink):
- skill.go/skill_version.go/validate.go/inputs.go/schedule.go moved clean; the
  only host couplings severed: llms.IsTierName -> model.IsTierName, and the
  chatbot DefaultChatbotInputName const localized.
- store.go: a DELIBERATELY LEAN SkillStore — skill lifecycle (CRUD + visibility)
  + versioning + scheduling ONLY. The KV/file/quota sub-stores that were fused
  into mort's interface are the tools/ store seams; email/channel grants stay
  host concerns.
- runnable.go: Skill.ToRunnable() lowers a skill into run.RunnableAgent (flat
  tool list, no palette — composition is a host concern); DueAt() helper.
- memory.go: NewMemory() — zero-dep in-process SkillStore (visibility filters,
  newest-first versions).

Tests: ToRunnable mapping, visibility (public/shared/private) listing, version
ordering + lookup. No mort dependency (go.mod tidy clean); core imports ZERO
from skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:40:58 -04:00

425 lines
19 KiB
Go

// 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
}