c8559676ed
executus CI / test (push) Has been cancelled
Merges the skill half of the persona/skill pair plus the second nested module. (Squashed onto main from phase-4b-skill; the audit/budget/persona batteries it was stacked on already landed via the P4 merge.) - skill/: clean-redesign Skill noun + LEAN SkillStore (lifecycle/versions/ schedule only) + ToRunnable + Memory default. - contrib/store/: separate go.mod carrying modernc.org/sqlite, so the driver never enters the core go.sum. db.Budget()/Personas()/Skills()/Audit() back all four store seams (JSON-blob + indexed columns; round-trip tested). Includes the verified gadfly #5 fixes (AppendVersion tx+UNIQUE+error, Mark*ScheduledRun atomic json_set, busy_timeout, NaN guard). - CI: builds + tests the nested module and asserts it owns the sqlite driver. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
425 lines
19 KiB
Go
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
|
|
}
|