1 Commits

Author SHA1 Message Date
steve 8ecdadf8b8 feat: first-class skill packs on agents + ship gifsmith builtin
executus CI / test (push) Successful in 3m21s
Lifts the 'an agent uses a SKILL.md pack' concept out of a host and into the
harness:
- run.Ports.SkillPacks (SkillPackActivator) — nil-safe port; the executor folds
  a loaded agent's pack catalog into the system prompt and adds a skill_use
  loader tool to the toolbox (uses the existing ra.SystemPrompt + toolbox seams)
- run.RunnableAgent.SkillPacks + persona.Agent.SkillPacks (+ skill_packs YAML,
  extends-inherit, ToRunnable) — the Agent noun is now pack-aware
- skillpack.Activator — the battery's default port impl (resolve names → packs →
  catalog + skill_use), with a per-run BundleStager factory the host plumbs;
  satisfies the port structurally (no import of run)
- agentbuiltins: ships gifsmith, a portable focused GIF/MP4 render agent that
  uses the gif pack — references tool/tier/pack NAMES only, no host coupling

A host now wires run.Ports.SkillPacks instead of carrying its own activation
glue. Tests: Activator resolution + gifsmith loads through persona→RunnableAgent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:05:58 -04:00
11 changed files with 279 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# gifsmith — a portable, focused render agent that makes animated GIFs/MP4s via
# the `gif` skill pack. Shipped by executus (agentbuiltins), run by any host that
# provides tools with these names, a `thinking` model tier, and the `gif` pack.
# Nothing here is host-specific — the names are the contract the host binds.
name: gifsmith
description: >-
Makes a funny animated GIF (or an MP4 when the piece is long or a GIF is too
big) from a description, via the gif skill pack. A single-purpose render agent
— use it for any request to draw/animate/gif something, including multi-minute
bits about people or things that happened.
model_tier: thinking
system_prompt: |
You make funny animated GIFs and MP4s from a description — often caricatures of
the people in the channel or a bit about something that happened. Work by
calling tools; do NOT introduce yourself or list capabilities.
Load the `gif` skill FIRST: call skill_use with name `gif` to get the full
recipe (scene/cast planning, the code_exec workspace rules, the bundled encode
helper, and the GIF-vs-MP4 size/length decision), then follow it exactly to
render and deliver the result. The skill also bundles an encode helper that
picks GIF vs MP4 and guarantees a Discord-playable MP4 — use it, don't hand-roll
the encode.
Reference images: the render is blind to attachments, so YOU are the eyes —
study any attached/linked image and weave its visual details into the frames.
If you can't make it out, proceed from the words.
low_level_tools:
- code_exec
- image_describe
- send_attachments
- file_get_metadata
- file_save
- think
skill_packs:
- gif
execution_lane: animate
max_iterations: 50
max_tool_calls: 80
max_runtime_seconds: 1800
critic_enabled: true
default_emoji: "🎬"
state_react:
__start__: "🎬"
code_exec: "🐍"
image_describe: "🖼️"
think: "🧠"
send_attachments: "📎"
__end__: "✅"
__error__: "❌"
+24
View File
@@ -0,0 +1,24 @@
// Package agentbuiltins ships executus's canonical builtin agent definitions as
// an embedded filesystem. They are portable persona manifests
// (agents/<name>/agent.yml): each references tool NAMES, a model-tier NAME, and
// skill-pack names — the host binds those to implementations. Nothing here
// imports a host or a battery, so any executus consumer can seed these via
// persona.LoadBuiltinAgents (or its own loader that reads the same schema):
//
// persona.LoadBuiltinAgents(ctx, store, agentbuiltins.FS(), skillChecker)
//
// Ships:
// - gifsmith — a focused GIF/MP4 render agent that uses the `gif` skill pack.
package agentbuiltins
import (
"embed"
"io/fs"
)
//go:embed agents
var embedded embed.FS
// FS returns the builtin agents tree, rooted so that a loader finds each
// definition at agents/<name>/agent.yml (the layout LoadBuiltinAgents expects).
func FS() fs.FS { return embedded }
+42
View File
@@ -0,0 +1,42 @@
package agentbuiltins_test
import (
"context"
"slices"
"testing"
"gitea.stevedudenhoeffer.com/steve/executus/agentbuiltins"
"gitea.stevedudenhoeffer.com/steve/executus/persona"
)
// TestGifsmithLoads proves executus's shipped gifsmith manifest flows through
// the persona loader and lowers into a RunnableAgent carrying the gif pack — the
// path a host uses to dogfood it.
func TestGifsmithLoads(t *testing.T) {
ctx := context.Background()
store := persona.NewMemory()
n, err := persona.LoadBuiltinAgents(ctx, store, agentbuiltins.FS(), nil)
if err != nil {
t.Fatal(err)
}
if n < 1 {
t.Fatalf("expected gifsmith seeded, got %d", n)
}
a, err := store.GetAgentByName(ctx, persona.BuiltinAgentOwnerID, "gifsmith")
if err != nil {
t.Fatal(err)
}
if len(a.SkillPacks) != 1 || a.SkillPacks[0] != "gif" {
t.Errorf("skill_packs = %v", a.SkillPacks)
}
if a.ModelTier != "thinking" {
t.Errorf("model_tier = %q (want a portable tier name)", a.ModelTier)
}
if !slices.Contains(a.LowLevelTools, "code_exec") || !slices.Contains(a.LowLevelTools, "send_attachments") {
t.Errorf("low_level_tools missing render/deliver tools: %v", a.LowLevelTools)
}
// The pack must survive the lowering the executor consumes.
if ra := a.ToRunnable(); len(ra.SkillPacks) != 1 || ra.SkillPacks[0] != "gif" {
t.Errorf("RunnableAgent.SkillPacks = %v", ra.SkillPacks)
}
}
+3
View File
@@ -86,6 +86,9 @@ type Agent struct {
SkillPalette []string // skill IDs/names
SubAgentPalette []string // agent IDs/names
LowLevelTools []string // skilltools registry names
// SkillPacks names SKILL.md skill-pack subscriptions activated for a run via
// run.Ports.SkillPacks (catalog folded into the prompt + a skill_use loader).
SkillPacks []string
// Personalization (Phase 5 reads these). Each layer name maps to
// a registered PersonalizationProvider that returns text appended
+5
View File
@@ -291,6 +291,9 @@ func resolveExtends(child, parent *Agent) {
if child.LowLevelTools == nil {
child.LowLevelTools = parent.LowLevelTools
}
if child.SkillPacks == nil {
child.SkillPacks = parent.SkillPacks
}
if child.PersonalizationSources == nil {
child.PersonalizationSources = parent.PersonalizationSources
}
@@ -456,6 +459,7 @@ type builtinAgentManifest struct {
SkillPalette []string `yaml:"skill_palette"`
SubAgentPalette []string `yaml:"sub_agent_palette"`
LowLevelTools []string `yaml:"low_level_tools"`
SkillPacks []string `yaml:"skill_packs"`
PersonalizationSources []string `yaml:"personalization_sources"`
@@ -562,6 +566,7 @@ func decodeAgentManifest(data []byte) (*Agent, error) {
SkillPalette: m.SkillPalette,
SubAgentPalette: m.SubAgentPalette,
LowLevelTools: m.LowLevelTools,
SkillPacks: m.SkillPacks,
PersonalizationSources: m.PersonalizationSources,
Schedule: strings.TrimSpace(m.Schedule),
WebhookIPAllowlist: allowlist,
+1
View File
@@ -18,6 +18,7 @@ func (a *Agent) ToRunnable() run.RunnableAgent {
LowLevelTools: a.LowLevelTools,
SkillPalette: a.SkillPalette,
SubAgentPalette: a.SubAgentPalette,
SkillPacks: a.SkillPacks,
Critic: run.CriticConfig{
Enabled: a.CriticEnabled,
BackstopMultiplier: a.CriticBackstopMultiplier,
+5
View File
@@ -44,6 +44,11 @@ type RunnableAgent struct {
LowLevelTools []string
SkillPalette []string
SubAgentPalette []string
// SkillPacks names SKILL.md skill-pack subscriptions activated for the run
// via Ports.SkillPacks: each pack's name+description joins a catalog folded
// into the system prompt, and a skill_use tool loads a pack's body on demand
// (progressive disclosure). nil Ports.SkillPacks => inert.
SkillPacks []string
// Phases optionally model a multi-step pipeline (each phase its own prompt
// + tier + tools). An empty slice is a single-phase run — the common case.
+26
View File
@@ -275,6 +275,32 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
postRun = st.PostRun
}
// Skill packs: resolve the agent's subscribed packs into a catalog (folded
// into the system prompt) + a skill_use loader tool added to the toolbox.
// nil-safe; activation failures are non-fatal — the run proceeds without
// packs rather than dying on a fetch/cache miss.
if len(ra.SkillPacks) > 0 && e.cfg.Ports.SkillPacks != nil {
instr, packTools, aerr := e.cfg.Ports.SkillPacks.ActivateSkillPacks(ctx, ra.SkillPacks, inv.RunID, ra.ID)
if aerr != nil {
slog.Warn("run: skill-pack activation failed; continuing without packs", "run_id", inv.RunID, "error", aerr)
} else {
for _, t := range packTools {
if err := toolbox.Add(t); err != nil {
res.Err = fmt.Errorf("add skill-pack tool: %w", err)
e.finishAudit(ctx, rec, "error", res, started, res.Err)
return res
}
}
if instr != "" {
if ra.SystemPrompt != "" {
ra.SystemPrompt += "\n\n" + instr
} else {
ra.SystemPrompt = instr
}
}
}
}
// Run context: detached from the caller's deadline so a lane/queue wait doesn't
// eat the run budget (mort's V10 lesson). Caller cancellation still propagates
// via MergeCancellation. Created BEFORE the step observer so the observer
+16
View File
@@ -49,6 +49,22 @@ type Ports struct {
// are silently ignored (the run still proceeds, text-only). The bytes are
// never inlined into the model context — the LLM can't read raw audio/binary.
InputFiles InputFileStager
// SkillPacks activates a RunnableAgent.SkillPacks (SKILL.md subscriptions)
// for the run: it folds a catalog into the system prompt and adds a skill_use
// loader tool. nil = SkillPacks are inert. The executus/skillpack battery
// ships a default impl (skillpack.Activator).
SkillPacks SkillPackActivator
}
// SkillPackActivator resolves an agent's subscribed skill-pack names for a run
// into system-prompt instructions (a catalog of what's available on demand) and
// the tools that back them (a single skill_use loader). It receives the run +
// subject ids so the impl can scope any per-run file staging. It returns "" +
// nil when nothing resolves; activation errors are non-fatal to the run. Defined
// here (the consumer) so the battery satisfies it structurally without importing
// run — the same inversion as the other ports.
type SkillPackActivator interface {
ActivateSkillPacks(ctx context.Context, names []string, runID, subjectID string) (instructions string, tools []llm.Tool, err error)
}
// InputFileStager persists a single non-image input attachment into a host file
+58
View File
@@ -0,0 +1,58 @@
package skillpack
import (
"context"
"errors"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// Activator adapts the battery to executus/run's SkillPackActivator port: given
// an agent's subscribed pack names, it resolves them to their pinned packs and
// returns the catalog instructions + the skill_use tool the run injects. It
// satisfies run.SkillPackActivator structurally — no import of run — so the
// battery stays run-agnostic (the same inversion as the other batteries).
//
// StagerFor, when set, builds the per-run BundleStager (a host plumbs bundled
// files into its own run-scoped storage from the run + subject ids); nil means
// skill_use lists a pack's bundled filenames without staging them.
type Activator struct {
Cache PackCache
Subs Store
StagerFor func(runID, subjectID string) BundleStager
}
// ActivateSkillPacks implements run.SkillPackActivator. Unknown or disabled pack
// names are skipped; it returns "" + nil when nothing resolves.
func (a *Activator) ActivateSkillPacks(ctx context.Context, names []string, runID, subjectID string) (string, []llm.Tool, error) {
if a == nil || a.Subs == nil || a.Cache == nil || len(names) == 0 {
return "", nil, nil
}
chosen := make([]Subscription, 0, len(names))
for _, n := range names {
sub, err := a.Subs.GetByName(ctx, n)
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return "", nil, err
}
if !sub.Enabled {
continue
}
chosen = append(chosen, *sub)
}
packs, err := Resolve(ctx, a.Cache, chosen)
if err != nil {
return "", nil, err
}
var stager BundleStager
if a.StagerFor != nil {
stager = a.StagerFor(runID, subjectID)
}
sk := Activate(packs, stager)
if sk == nil {
return "", nil, nil
}
return sk.Instructions(), sk.Tools().Tools(), nil
}
+50
View File
@@ -0,0 +1,50 @@
package skillpack
import (
"context"
"testing"
)
func TestActivator(t *testing.T) {
ctx := context.Background()
src := &fakeSource{tree: packTree("alpha", "do alpha things"), ref: "r1"}
y := newTestSyncer(src)
if _, err := y.Subscribe(ctx, src, "main", "steve"); err != nil {
t.Fatal(err)
}
staged := 0
act := &Activator{
Cache: y.Cache, Subs: y.Subs,
StagerFor: func(runID, subjectID string) BundleStager {
return func(context.Context, *Pack) (string, error) { staged++; return "", nil }
},
}
instr, tools, err := act.ActivateSkillPacks(ctx, []string{"alpha"}, "run1", "agent1")
if err != nil {
t.Fatal(err)
}
if instr == "" {
t.Error("expected catalog instructions")
}
found := false
for _, tl := range tools {
if tl.Name == "skill_use" {
found = true
}
}
if !found {
t.Errorf("expected a skill_use tool, got %d tools", len(tools))
}
// unknown name → nothing resolves (no error, no tools).
if in, tl, err := act.ActivateSkillPacks(ctx, []string{"nope"}, "r", "a"); err != nil || in != "" || tl != nil {
t.Fatalf("unknown pack should resolve to nothing: in=%q tools=%v err=%v", in, tl, err)
}
// nil-safe: a zero Activator (or empty names) is inert.
if in, tl, err := (&Activator{}).ActivateSkillPacks(ctx, []string{"alpha"}, "r", "a"); err != nil || in != "" || tl != nil {
t.Fatalf("zero Activator should be inert: %q %v %v", in, tl, err)
}
}