feat: first-class skill packs on agents + ship gifsmith builtin
executus CI / test (push) Successful in 3m21s
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>
This commit is contained in:
@@ -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__: "❌"
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,9 @@ type Agent struct {
|
|||||||
SkillPalette []string // skill IDs/names
|
SkillPalette []string // skill IDs/names
|
||||||
SubAgentPalette []string // agent IDs/names
|
SubAgentPalette []string // agent IDs/names
|
||||||
LowLevelTools []string // skilltools registry 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
|
// Personalization (Phase 5 reads these). Each layer name maps to
|
||||||
// a registered PersonalizationProvider that returns text appended
|
// a registered PersonalizationProvider that returns text appended
|
||||||
|
|||||||
@@ -291,6 +291,9 @@ func resolveExtends(child, parent *Agent) {
|
|||||||
if child.LowLevelTools == nil {
|
if child.LowLevelTools == nil {
|
||||||
child.LowLevelTools = parent.LowLevelTools
|
child.LowLevelTools = parent.LowLevelTools
|
||||||
}
|
}
|
||||||
|
if child.SkillPacks == nil {
|
||||||
|
child.SkillPacks = parent.SkillPacks
|
||||||
|
}
|
||||||
if child.PersonalizationSources == nil {
|
if child.PersonalizationSources == nil {
|
||||||
child.PersonalizationSources = parent.PersonalizationSources
|
child.PersonalizationSources = parent.PersonalizationSources
|
||||||
}
|
}
|
||||||
@@ -456,6 +459,7 @@ type builtinAgentManifest struct {
|
|||||||
SkillPalette []string `yaml:"skill_palette"`
|
SkillPalette []string `yaml:"skill_palette"`
|
||||||
SubAgentPalette []string `yaml:"sub_agent_palette"`
|
SubAgentPalette []string `yaml:"sub_agent_palette"`
|
||||||
LowLevelTools []string `yaml:"low_level_tools"`
|
LowLevelTools []string `yaml:"low_level_tools"`
|
||||||
|
SkillPacks []string `yaml:"skill_packs"`
|
||||||
|
|
||||||
PersonalizationSources []string `yaml:"personalization_sources"`
|
PersonalizationSources []string `yaml:"personalization_sources"`
|
||||||
|
|
||||||
@@ -562,6 +566,7 @@ func decodeAgentManifest(data []byte) (*Agent, error) {
|
|||||||
SkillPalette: m.SkillPalette,
|
SkillPalette: m.SkillPalette,
|
||||||
SubAgentPalette: m.SubAgentPalette,
|
SubAgentPalette: m.SubAgentPalette,
|
||||||
LowLevelTools: m.LowLevelTools,
|
LowLevelTools: m.LowLevelTools,
|
||||||
|
SkillPacks: m.SkillPacks,
|
||||||
PersonalizationSources: m.PersonalizationSources,
|
PersonalizationSources: m.PersonalizationSources,
|
||||||
Schedule: strings.TrimSpace(m.Schedule),
|
Schedule: strings.TrimSpace(m.Schedule),
|
||||||
WebhookIPAllowlist: allowlist,
|
WebhookIPAllowlist: allowlist,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func (a *Agent) ToRunnable() run.RunnableAgent {
|
|||||||
LowLevelTools: a.LowLevelTools,
|
LowLevelTools: a.LowLevelTools,
|
||||||
SkillPalette: a.SkillPalette,
|
SkillPalette: a.SkillPalette,
|
||||||
SubAgentPalette: a.SubAgentPalette,
|
SubAgentPalette: a.SubAgentPalette,
|
||||||
|
SkillPacks: a.SkillPacks,
|
||||||
Critic: run.CriticConfig{
|
Critic: run.CriticConfig{
|
||||||
Enabled: a.CriticEnabled,
|
Enabled: a.CriticEnabled,
|
||||||
BackstopMultiplier: a.CriticBackstopMultiplier,
|
BackstopMultiplier: a.CriticBackstopMultiplier,
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ type RunnableAgent struct {
|
|||||||
LowLevelTools []string
|
LowLevelTools []string
|
||||||
SkillPalette []string
|
SkillPalette []string
|
||||||
SubAgentPalette []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
|
// 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.
|
// + tier + tools). An empty slice is a single-phase run — the common case.
|
||||||
|
|||||||
@@ -275,6 +275,32 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
|
|||||||
postRun = st.PostRun
|
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
|
// 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
|
// eat the run budget (mort's V10 lesson). Caller cancellation still propagates
|
||||||
// via MergeCancellation. Created BEFORE the step observer so the observer
|
// via MergeCancellation. Created BEFORE the step observer so the observer
|
||||||
|
|||||||
@@ -49,6 +49,22 @@ type Ports struct {
|
|||||||
// are silently ignored (the run still proceeds, text-only). The bytes are
|
// 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.
|
// never inlined into the model context — the LLM can't read raw audio/binary.
|
||||||
InputFiles InputFileStager
|
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
|
// InputFileStager persists a single non-image input attachment into a host 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user