diff --git a/agentbuiltins/agents/gifsmith/agent.yml b/agentbuiltins/agents/gifsmith/agent.yml new file mode 100644 index 0000000..25f0aab --- /dev/null +++ b/agentbuiltins/agents/gifsmith/agent.yml @@ -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__: "❌" diff --git a/agentbuiltins/builtins.go b/agentbuiltins/builtins.go new file mode 100644 index 0000000..609247e --- /dev/null +++ b/agentbuiltins/builtins.go @@ -0,0 +1,24 @@ +// Package agentbuiltins ships executus's canonical builtin agent definitions as +// an embedded filesystem. They are portable persona manifests +// (agents//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//agent.yml (the layout LoadBuiltinAgents expects). +func FS() fs.FS { return embedded } diff --git a/agentbuiltins/builtins_test.go b/agentbuiltins/builtins_test.go new file mode 100644 index 0000000..7f93c3d --- /dev/null +++ b/agentbuiltins/builtins_test.go @@ -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) + } +} diff --git a/persona/agent.go b/persona/agent.go index 19fbce0..3c12188 100644 --- a/persona/agent.go +++ b/persona/agent.go @@ -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 diff --git a/persona/builtin_loader.go b/persona/builtin_loader.go index 6e0b71d..41abd9d 100644 --- a/persona/builtin_loader.go +++ b/persona/builtin_loader.go @@ -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, diff --git a/persona/runnable.go b/persona/runnable.go index 7a4bafd..5cbc41a 100644 --- a/persona/runnable.go +++ b/persona/runnable.go @@ -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, diff --git a/run/agent.go b/run/agent.go index 9b46fca..12c8186 100644 --- a/run/agent.go +++ b/run/agent.go @@ -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. diff --git a/run/executor.go b/run/executor.go index 7a3ffb3..0853808 100644 --- a/run/executor.go +++ b/run/executor.go @@ -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 diff --git a/run/ports.go b/run/ports.go index 9713cb6..c5ebe84 100644 --- a/run/ports.go +++ b/run/ports.go @@ -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 diff --git a/skillpack/activator.go b/skillpack/activator.go new file mode 100644 index 0000000..76c9f73 --- /dev/null +++ b/skillpack/activator.go @@ -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 +} diff --git a/skillpack/activator_test.go b/skillpack/activator_test.go new file mode 100644 index 0000000..70cda5a --- /dev/null +++ b/skillpack/activator_test.go @@ -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) + } +}