Author SHA1 Message Date
steveandClaude Fable 5 007b7539c2 feat(run): Ports.FinalGuard — host seam that nudges an announce-then-stop run to deliver its artifact
Gadfly review (reusable) / review (pull_request) Successful in 29s
Adversarial Review (Gadfly) / review (pull_request) Successful in 28s
executus CI / test (pull_request) Successful in 3m1s
A run can render an artifact into host storage, end on a text-only stop
turn claiming "Done — sent you the file", and never call the host's
delivery tool — the artifact strands while the user is told it shipped
(mort general run e9e7bf40 2026-07-14, gifsmith run 29170c93 2026-07-12).
The loop has no seam for the host to intervene at the stop turn.

Add Ports.FinalGuard: consulted once when a single-loop run is about to
finalize successfully. A non-empty nudge is appended to the SAME
conversation as the next user turn and the agent runs ONE bounded extra
round (same toolbox/observers, capped at finalNudgeMaxSteps=6) — enough
to deliver the stranded file or correct the final text, not enough to
start a new work spree. Guard panics and nudge-round failures are
isolated: the original successful result always survives. The nudge is
recorded as a final_nudge audit event and appended to the durable
checkpoint transcript.

stageInputFiles now also returns the staged file ids, surfaced to the
guard via FinalState.StagedFileIDs — staged inputs live under run scope
but are the user's attachments, not run-produced artifacts, so an
undelivered-artifact check must exclude them. FinalState.ToolNames lets
a host skip runs that have no delivery tool at all.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-14 23:14:09 -04:00
steveandClaude Opus 4.8 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 <[email protected]>
2026-07-05 01:05:58 -04:00
steve d5ea9b6e5e Merge pull request 'feat(skillpack): SKILL.md-subscription battery' (#22) from feat/skillpack-battery into main
executus CI / test (push) Successful in 3m3s
2026-07-05 01:28:57 +00:00
15 changed files with 725 additions and 18 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.
+79 -2
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
@@ -403,8 +429,10 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
// store and fold an [ATTACHED FILES] descriptor into the prompt so the agent
// can reach them by file_id. No-op when Ports.InputFiles is nil or there are
// no files. Done after the model/toolbox build but before the loop, so the
// descriptor rides the very first user turn.
input = e.stageInputFiles(runCtx, inv.RunID, ra.ID, inv.InputFiles, input)
// descriptor rides the very first user turn. The staged file ids are kept for
// the FinalGuard: they live under run scope but are the user's INPUTS, not
// run-produced artifacts, so an undelivered-artifact check must exclude them.
input, stagedFileIDs := e.stageInputFiles(runCtx, inv.RunID, ra.ID, inv.InputFiles, input)
// One WithSteer drains BOTH the session mailbox (a tool's AttachImages) and
// the critic's nudges before each step.
steer := func() []llm.Message { return append(mailbox.drain(), critic.drainSteer()...) }
@@ -426,6 +454,10 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
// step responses, not the loop's compacted history) — a host that caps size
// bounds it. A recovered run seeds the saved transcript and continues.
obs := stepObserver
// noteCheckpoint appends an out-of-loop message (the FinalGuard nudge) to
// the checkpoint transcript so a shutdown-resume of a nudged run replays
// the nudge turn too. nil when the run is non-durable.
var noteCheckpoint func(llm.Message)
if ckpt != nil {
var acc []llm.Message
if resuming {
@@ -443,6 +475,7 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
}
_ = ckpt.Save(runCtx, RunCheckpointState{Messages: acc, Iteration: s.Index + 1})
}
noteCheckpoint = func(m llm.Message) { acc = append(acc, m) }
}
opts := append([]agent.Option{
agent.WithToolbox(toolbox),
@@ -457,6 +490,50 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio
} else {
runRes, runErr = runAgent(runCtx, ag, input, inv.Images, agent.WithSteer(steer))
}
// FinalGuard (optional): the run finished successfully — before the result
// is finalized, let the host inspect it for undelivered work (the
// announce-then-stop failure: an artifact rendered into run storage, a
// final text claiming "Done", and no delivery tool call). A non-empty
// nudge runs ONE bounded extra round on the same conversation: same model,
// same toolbox, same observers (audit/steps/critic all see it), but a
// small fixed step cap so a misread nudge can't start a new work spree.
// The nudge round's failure is non-fatal — the original successful result
// stands (any attachments it queued before failing still deliver).
if runErr == nil && runRes != nil && e.cfg.Ports.FinalGuard != nil {
state := FinalState{
Output: runRes.Output,
ToolNames: toolboxNames(toolbox),
StagedFileIDs: stagedFileIDs,
}
if nudge := safeFinalNudge(runCtx, e.cfg.Ports.FinalGuard, info, state); nudge != "" {
if rec != nil {
rec.LogEvent("final_nudge", map[string]any{"nudge": nudge})
}
if noteCheckpoint != nil {
noteCheckpoint(llm.UserText(nudge))
}
nudgeOpts := append([]agent.Option{
agent.WithToolbox(toolbox),
agent.WithMaxSteps(finalNudgeMaxSteps),
agent.WithStepObserver(obs),
}, sharedOpts...)
nudgeAg := agent.New(model, e.systemPrompt(ra), nudgeOpts...)
nudgeRes, nudgeErr := nudgeAg.Run(runCtx, nudge,
agent.WithSteer(steer), agent.WithHistory(runRes.Messages))
if nudgeRes != nil {
// The extra round's tokens are real spend either way.
runRes.Usage.Add(nudgeRes.Usage)
}
if nudgeErr != nil {
slog.Warn("run: final-nudge round failed; keeping original result",
"run_id", inv.RunID, "error", nudgeErr)
} else if nudgeRes != nil {
runRes.Output = nudgeRes.Output
runRes.Messages = nudgeRes.Messages
}
}
}
} else {
// Multi-phase pipeline: each phase runs its own prompt/tier/tools/step-cap
// sequentially, threading outputs through {{.<PhaseName>}} templates. The
+80
View File
@@ -0,0 +1,80 @@
package run
import (
"context"
"log/slog"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// FinalGuard is the optional host seam consulted when a single-loop run is
// about to finalize SUCCESSFULLY (the model produced a final answer and no run
// error occurred). It exists for the "announce-then-stop" failure: an agent
// renders an artifact into host storage, narrates "Done — sent you the file",
// and ends the run without ever calling the host's delivery tool — so nothing
// reaches the user while the final text claims otherwise (observed live:
// gifsmith run 29170c93 2026-07-12, general run e9e7bf40 2026-07-14).
//
// FinalNudge returns "" to let the run finish as-is (the overwhelmingly common
// case — the host should make its check cheap), or a non-empty nudge message.
// A non-empty nudge sends the loop back for ONE bounded extra round: the nudge
// is appended to the SAME conversation as the next user turn and the agent
// runs again with the same toolbox, capped at finalNudgeMaxSteps steps — enough
// to deliver a stranded artifact (or correct the final text), not enough to
// start a new project. The guard is consulted at most ONCE per run; the nudge
// round's own stop turn is final. Multi-phase runs are not guarded (their
// output contract is the phase pipeline's, not a delivery surface's).
//
// The call runs on the run goroutine, inside the run's deadline, and is
// panic-isolated: a guard panic is logged and treated as "" (the run's
// successful result must never be lost to a guard bug).
type FinalGuard interface {
FinalNudge(ctx context.Context, info RunInfo, state FinalState) string
}
// FinalState is the snapshot a FinalGuard receives about the finishing run.
type FinalState struct {
// Output is the run's final answer text (what the host would deliver).
Output string
// ToolNames lists the tools that were available to the run, so a guard can
// skip nudging a run that has no way to act (e.g. no send_attachments).
ToolNames []string
// StagedFileIDs are the file ids the executor staged from the invocation's
// input attachments (Ports.InputFiles). They live under the run's file scope
// but are the USER's inputs, not run-produced artifacts — a guard checking
// for undelivered artifacts must exclude them.
StagedFileIDs []string
}
// finalNudgeMaxSteps caps the nudge round's tool-dispatch steps. Delivering a
// stranded artifact needs at most a file_list + a send_attachments + a final
// text turn; the cap keeps a model that misreads the nudge from launching a
// whole new work spree on the host's budget.
const finalNudgeMaxSteps = 6
// safeFinalNudge consults the guard behind a recover so a guard panic degrades
// to "no nudge" instead of converting a successful run into a panic-error (the
// executor's top-level recover would otherwise stamp res.Err).
func safeFinalNudge(ctx context.Context, g FinalGuard, info RunInfo, state FinalState) (nudge string) {
defer func() {
if r := recover(); r != nil {
slog.Error("run: FinalGuard panicked; skipping nudge",
"run_id", info.RunID, "panic", r)
nudge = ""
}
}()
return g.FinalNudge(ctx, info, state)
}
// toolboxNames flattens the run toolbox's tool names for FinalState (nil-safe).
func toolboxNames(b *llm.Toolbox) []string {
if b == nil {
return nil
}
tools := b.Tools()
names := make([]string, 0, len(tools))
for _, t := range tools {
names = append(names, t.Name)
}
return names
}
+287
View File
@@ -0,0 +1,287 @@
package run
import (
"context"
"encoding/json"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// guardFunc adapts a func to FinalGuard, counting calls and capturing the
// snapshot it received.
type guardFunc struct {
nudge func(info RunInfo, state FinalState) string
calls int
info RunInfo
state FinalState
}
func (g *guardFunc) FinalNudge(_ context.Context, info RunInfo, state FinalState) string {
g.calls++
g.info = info
g.state = state
return g.nudge(info, state)
}
// eventRecorder is captureRecorder plus LogEvent capture (for the final_nudge
// audit event).
type eventRecorder struct {
captureRecorder
events []string
}
func (r *eventRecorder) LogEvent(eventType string, _ map[string]any) {
r.events = append(r.events, eventType)
}
// TestFinalGuardNudgeRunsOneExtraRound is the core announce-then-stop fix: the
// model stops with "Done — sent!" (nothing delivered), the guard returns a
// nudge, and the SAME conversation runs one more round — here a tool call then
// a corrected final answer. The guard is consulted exactly once (the nudge
// round's own stop is final), and the audit log records the nudge.
func TestFinalGuardNudgeRunsOneExtraRound(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m",
fake.Reply("Done — sent you the file!"),
// Nudge round: an (unregistered → tolerated error result) delivery call,
// then the corrected final answer.
fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{ID: "c1", Name: "send_attachments", Arguments: []byte(`{}`)}}}),
fake.Reply("delivered for real"),
)
m, _ := fp.Model("m")
rec := &eventRecorder{}
guard := &guardFunc{nudge: func(RunInfo, FinalState) string { return "you did not deliver the file" }}
ex := New(Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
Ports: Ports{
Audit: auditFunc{start: func(RunInfo) RunRecorder { return rec }},
FinalGuard: guard,
},
})
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-nudge", CallerID: "c"}, "make me a file")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != "delivered for real" {
t.Fatalf("output = %q, want the nudge round's answer", res.Output)
}
if guard.calls != 1 {
t.Fatalf("guard consulted %d times, want exactly 1 (no re-guard of the nudge round)", guard.calls)
}
if guard.info.RunID != "r-nudge" {
t.Errorf("guard info.RunID = %q", guard.info.RunID)
}
if guard.state.Output != "Done — sent you the file!" {
t.Errorf("guard state.Output = %q, want the pre-nudge final text", guard.state.Output)
}
var sawNudgeEvent bool
for _, ev := range rec.events {
if ev == "final_nudge" {
sawNudgeEvent = true
}
}
if !sawNudgeEvent {
t.Errorf("audit events %v missing final_nudge", rec.events)
}
if rec.stats.Output != "delivered for real" {
t.Errorf("audit close output = %q, want the nudged output", rec.stats.Output)
}
}
// TestFinalGuardEmptyNudgeNoExtraRound: a guard returning "" adds zero model
// calls — the common path stays one round.
func TestFinalGuardEmptyNudgeNoExtraRound(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("plain answer"))
m, _ := fp.Model("m")
guard := &guardFunc{nudge: func(RunInfo, FinalState) string { return "" }}
ex := New(Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
Ports: Ports{FinalGuard: guard},
})
res := ex.Run(context.Background(),
RunnableAgent{ModelTier: "m"}, tool.Invocation{RunID: "r"}, "hi")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != "plain answer" {
t.Fatalf("output = %q", res.Output)
}
if guard.calls != 1 {
t.Errorf("guard consulted %d times, want 1", guard.calls)
}
if n := fp.CallCount("m"); n != 1 {
t.Errorf("model called %d times, want 1 (empty nudge must not re-run)", n)
}
}
// TestFinalGuardNudgeRoundFailureKeepsResult: the nudge round erroring (model
// failure, deadline, step cap) must NOT damage the original successful result.
func TestFinalGuardNudgeRoundFailureKeepsResult(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m",
fake.Reply("original answer"),
fake.Fail(errors.New("provider fell over")),
)
m, _ := fp.Model("m")
guard := &guardFunc{nudge: func(RunInfo, FinalState) string { return "deliver it" }}
ex := New(Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
Ports: Ports{FinalGuard: guard},
})
res := ex.Run(context.Background(),
RunnableAgent{ModelTier: "m"}, tool.Invocation{RunID: "r"}, "hi")
if res.Err != nil {
t.Fatalf("a failed nudge round must not fail the run: %v", res.Err)
}
if res.Output != "original answer" {
t.Fatalf("output = %q, want the original preserved", res.Output)
}
}
// TestFinalGuardNotConsultedOnRunError: a failed run is never nudged — the
// guard exists for successful-but-undelivered finishes only.
func TestFinalGuardNotConsultedOnRunError(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Fail(errors.New("boom")))
m, _ := fp.Model("m")
guard := &guardFunc{nudge: func(RunInfo, FinalState) string { return "nudge" }}
ex := New(Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
Ports: Ports{FinalGuard: guard},
})
res := ex.Run(context.Background(),
RunnableAgent{ModelTier: "m"}, tool.Invocation{RunID: "r"}, "hi")
if res.Err == nil {
t.Fatal("expected a run error")
}
if guard.calls != 0 {
t.Errorf("guard consulted %d times on a failed run, want 0", guard.calls)
}
}
// TestFinalGuardPanicIsolated: a panicking guard degrades to "no nudge" — the
// successful result survives untouched.
func TestFinalGuardPanicIsolated(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("safe answer"))
m, _ := fp.Model("m")
ex := New(Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
Ports: Ports{FinalGuard: panicGuard{}},
})
res := ex.Run(context.Background(),
RunnableAgent{ModelTier: "m"}, tool.Invocation{RunID: "r"}, "hi")
if res.Err != nil {
t.Fatalf("guard panic must not fail the run: %v", res.Err)
}
if res.Output != "safe answer" {
t.Fatalf("output = %q", res.Output)
}
}
type panicGuard struct{}
func (panicGuard) FinalNudge(context.Context, RunInfo, FinalState) string {
panic("guard bug")
}
// TestFinalGuardReceivesToolNamesAndStagedIDs: the guard's snapshot carries the
// run's tool names (via ExtraTools here) and the input-staged file ids, so a
// host can skip runs that can't deliver and exclude the user's own attachments.
func TestFinalGuardReceivesToolNamesAndStagedIDs(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("done"))
m, _ := fp.Model("m")
guard := &guardFunc{nudge: func(RunInfo, FinalState) string { return "" }}
ex := New(Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
Ports: Ports{
FinalGuard: guard,
InputFiles: guardStager(func(name string) string { return "staged-" + name }),
},
})
res := ex.Run(context.Background(),
RunnableAgent{ModelTier: "m"},
tool.Invocation{
RunID: "r",
ExtraTools: []llm.Tool{{
Name: "send_attachments",
Description: "deliver",
Handler: func(context.Context, json.RawMessage) (any, error) { return "ok", nil },
}},
InputFiles: []tool.InputFile{{Name: "input.wav", MimeType: "audio/wave", Data: []byte("x")}},
}, "hi")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
var sawTool bool
for _, n := range guard.state.ToolNames {
if n == "send_attachments" {
sawTool = true
}
}
if !sawTool {
t.Errorf("guard state.ToolNames = %v, want send_attachments present", guard.state.ToolNames)
}
if len(guard.state.StagedFileIDs) != 1 || guard.state.StagedFileIDs[0] != "staged-input.wav" {
t.Errorf("guard state.StagedFileIDs = %v, want [staged-input.wav]", guard.state.StagedFileIDs)
}
}
// guardStager maps an input file's name to a deterministic file id.
type guardStager func(name string) string
func (f guardStager) StageInputFile(_ context.Context, _, _, name, _ string, _ []byte) (string, error) {
return f(name), nil
}
// TestFinalGuardSkipsMultiPhaseRuns: a phase pipeline's output contract is the
// pipeline's, not a delivery surface's — the guard must not be consulted.
func TestFinalGuardSkipsMultiPhaseRuns(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("phase-out"))
m, _ := fp.Model("m")
guard := &guardFunc{nudge: func(RunInfo, FinalState) string { return "nudge" }}
ex := New(Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
Ports: Ports{FinalGuard: guard},
})
res := ex.Run(context.Background(),
RunnableAgent{ModelTier: "m", Phases: []Phase{{Name: "a", SystemPrompt: "do a"}}},
tool.Invocation{RunID: "r"}, "hi")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if guard.calls != 0 {
t.Errorf("guard consulted %d times on a phase run, want 0", guard.calls)
}
}
+11 -6
View File
@@ -30,10 +30,11 @@ const maxInputFiles = 32
//
// Best-effort: a nil stager, no files, or a per-file save error degrades to
// "skip that file" — the run still proceeds. Returns the (possibly augmented)
// prompt.
func (e *Executor) stageInputFiles(ctx context.Context, runID, agentID string, files []tool.InputFile, prompt string) string {
// prompt plus the staged file ids (for the FinalGuard: staged inputs live under
// run scope but are not run-produced artifacts).
func (e *Executor) stageInputFiles(ctx context.Context, runID, agentID string, files []tool.InputFile, prompt string) (string, []string) {
if e.cfg.Ports.InputFiles == nil || len(files) == 0 {
return prompt
return prompt, nil
}
// Count cap: bound how many attachments one run can stage, independent of the
// per-file byte cap (defense-in-depth against a flood of tiny files).
@@ -87,7 +88,11 @@ func (e *Executor) stageInputFiles(ctx context.Context, runID, agentID string, f
staged = append(staged, stagedFile{name: name, mime: mime, fileID: sanitizeField(fileID), size: len(f.Data)})
}
if len(staged) == 0 {
return prompt
return prompt, nil
}
stagedIDs := make([]string, 0, len(staged))
for _, s := range staged {
stagedIDs = append(stagedIDs, s.fileID)
}
var b strings.Builder
@@ -101,9 +106,9 @@ func (e *Executor) stageInputFiles(ctx context.Context, runID, agentID string, f
}
if strings.TrimSpace(prompt) == "" {
return b.String()
return b.String(), stagedIDs
}
return prompt + "\n\n" + b.String()
return prompt + "\n\n" + b.String(), stagedIDs
}
// sanitizeName reduces an untrusted attachment filename to a safe base name. It
+10 -10
View File
@@ -44,7 +44,7 @@ func newStagerExecutor(s InputFileStager) *Executor {
func TestStageInputFiles(t *testing.T) {
st := &stagerFunc{}
ex := newStagerExecutor(st)
out := ex.stageInputFiles(context.Background(), "run-1", "agent-1",
out, _ := ex.stageInputFiles(context.Background(), "run-1", "agent-1",
[]tool.InputFile{{Name: "clip.mp3", MimeType: "audio/mpeg", Data: []byte("abcd")}},
"transcribe this")
@@ -65,7 +65,7 @@ func TestStageInputFiles(t *testing.T) {
// drops the run.
func TestStageInputFilesNoStager(t *testing.T) {
ex := newStagerExecutor(nil) // Ports.InputFiles == nil
out := ex.stageInputFiles(context.Background(), "r", "a",
out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "x.bin", Data: []byte("z")}}, "prompt")
if out != "prompt" {
t.Errorf("nil stager changed the prompt: %q", out)
@@ -75,7 +75,7 @@ func TestStageInputFilesNoStager(t *testing.T) {
// TestStageInputFilesNoFiles: no attachments leaves the prompt untouched.
func TestStageInputFilesNoFiles(t *testing.T) {
ex := newStagerExecutor(&stagerFunc{})
out := ex.stageInputFiles(context.Background(), "r", "a", nil, "prompt")
out, _ := ex.stageInputFiles(context.Background(), "r", "a", nil, "prompt")
if out != "prompt" {
t.Errorf("no files changed the prompt: %q", out)
}
@@ -86,7 +86,7 @@ func TestStageInputFilesNoFiles(t *testing.T) {
func TestStageInputFilesDedup(t *testing.T) {
st := &stagerFunc{}
ex := newStagerExecutor(st)
out := ex.stageInputFiles(context.Background(), "r", "a", []tool.InputFile{
out, _ := ex.stageInputFiles(context.Background(), "r", "a", []tool.InputFile{
{Name: "a.wav", MimeType: "audio/wav", Data: []byte("1")},
{Name: "a.wav", MimeType: "audio/wav", Data: []byte("2")},
}, "go")
@@ -106,13 +106,13 @@ func TestStageInputFilesDedup(t *testing.T) {
func TestStageInputFilesSkipsBad(t *testing.T) {
// Empty data → skipped; with no good files the prompt is returned as-is.
ex := newStagerExecutor(&stagerFunc{})
if out := ex.stageInputFiles(context.Background(), "r", "a",
if out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "empty.bin", Data: nil}}, "p"); out != "p" {
t.Errorf("empty file should be skipped, got %q", out)
}
// A stager error → that file is dropped; nothing staged → prompt unchanged.
exErr := newStagerExecutor(&stagerFunc{err: errors.New("disk full")})
if out := exErr.stageInputFiles(context.Background(), "r", "a",
if out, _ := exErr.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "x.bin", Data: []byte("z")}}, "p"); out != "p" {
t.Errorf("save error should drop the file and leave the prompt, got %q", out)
}
@@ -124,7 +124,7 @@ func TestStageInputFilesOversize(t *testing.T) {
st := &stagerFunc{}
ex := newStagerExecutor(st)
big := make([]byte, maxInputFileBytes+1)
out := ex.stageInputFiles(context.Background(), "r", "a",
out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "huge.bin", Data: big}}, "p")
if out != "p" || len(st.staged) != 0 {
t.Errorf("oversized file should be skipped: out=%q staged=%d", out, len(st.staged))
@@ -177,7 +177,7 @@ func TestSanitizeName(t *testing.T) {
func TestStageInputFilesSanitizesTraversal(t *testing.T) {
st := &stagerFunc{}
ex := newStagerExecutor(st)
out := ex.stageInputFiles(context.Background(), "r", "a",
out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "../../../etc/passwd", MimeType: "text/plain", Data: []byte("x")}}, "go")
if len(st.staged) != 1 || st.staged[0].name != "passwd" {
t.Fatalf("staged name = %+v, want passwd", st.staged)
@@ -202,7 +202,7 @@ func TestSanitizeFieldStripsBidiAndControl(t *testing.T) {
func TestStageInputFilesSanitizesMime(t *testing.T) {
st := &stagerFunc{}
ex := newStagerExecutor(st)
out := ex.stageInputFiles(context.Background(), "r", "a",
out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "c.wav", MimeType: "audio/wav\ninjected", Data: []byte("x")}}, "go")
if len(st.staged) != 1 || strings.ContainsAny(st.staged[0].mime, "\n\r") {
t.Errorf("mime not sanitized before staging: %+v", st.staged)
@@ -216,7 +216,7 @@ func TestStageInputFilesSanitizesMime(t *testing.T) {
// file (no blank file_id in the descriptor).
func TestStageInputFilesEmptyFileID(t *testing.T) {
ex := newStagerExecutor(emptyIDStager{})
out := ex.stageInputFiles(context.Background(), "r", "a",
out, _ := ex.stageInputFiles(context.Background(), "r", "a",
[]tool.InputFile{{Name: "x.bin", Data: []byte("z")}}, "p")
if out != "p" {
t.Errorf("empty file_id should drop the file, got %q", out)
+21
View File
@@ -49,6 +49,27 @@ 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
// FinalGuard is consulted when a single-loop run is about to finalize
// successfully; a non-empty nudge sends the loop back for one bounded extra
// round (the announce-then-stop undelivered-artifact check). nil = runs
// finalize unguarded. See FinalGuard in final_guard.go.
FinalGuard FinalGuard
}
// 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)
}
}