From 9d41987b0e2a040900c54b5b5695e416c26bfa7d Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 27 Jun 2026 09:28:01 -0400 Subject: [PATCH 1/2] C0: wire Palette delegation into run.Executor (skill__/agent__ tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cutover prerequisite: the executor now turns an agent's SkillPalette / SubAgentPalette into delegation tools so a mort agent that delegates works through run.Executor (the piece the `.agent run` canary needs beyond the already-wired audit/budget). - run/palette.go: addDelegationTools builds a skill__ tool (structured inputs) per SkillPalette entry and an agent__ tool (prompt) per SubAgentPalette entry, each invoking run.Ports.Palette as a CHILD of the current run (parentRunID = inv.RunID, inheriting caller + channel). A non-ok child status is surfaced to the parent with the partial output. nil-safe: no PaletteSource or empty palette → no delegation tools (unchanged behavior). - executor.go: call it right after building the low-level toolbox. Tests: the model calls skill__helper → routed through Palette with the right name/caller/inputs/parent; nil palette → run still works. Deferred to C0b (the remaining run.Ports executor wiring): Critic (soft-timeout monitor + deadline binding + steer), Delivery (output egress for surfaces that need executor-side delivery), Checkpointer (needs a majordomo message-history hook to snapshot resumable state). The `.agent run` canary delivers its returned Result.Output itself, so these aren't on its critical path. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 +-- run/executor.go | 8 ++++ run/palette.go | 78 ++++++++++++++++++++++++++++++++++ run/palette_test.go | 101 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 run/palette.go create mode 100644 run/palette_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 3da73ce..d8963c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,9 +47,9 @@ CORE (majordomo + stdlib): toolbox + majordomo loop + compaction + run-bounding (V10 detached timeout) + step/ audit observers + Budget gate; RunnableAgent - DTO + nil-safe run.Ports. Follow-ups: wire - Critic/Checkpointer/PaletteSource/Delivery, - Phases, and the no-tools direct path [P2] + DTO + nil-safe run.Ports. Palette delegation + WIRED (skill__/agent__ tools, C0). Follow-ups: + wire Critic/Checkpointer/Delivery, Phases [C0b] dispatchguard/ loop/depth/fan-out caps [P0 ✓] pendingattach/ attachment dedupe [P0 ✓] tool/ registry + 3-stage permissions + ssrf [P1 ✓] diff --git a/run/executor.go b/run/executor.go index 53d6940..3b28866 100644 --- a/run/executor.go +++ b/run/executor.go @@ -168,6 +168,14 @@ func (e *Executor) Run(ctx context.Context, ra RunnableAgent, inv tool.Invocatio return res } + // Add skill__/agent__ delegation tools from the agent's palette (nil-safe: + // no PaletteSource or empty palette → no delegation tools). + if err := addDelegationTools(toolbox, ra, inv, e.cfg.Ports.Palette); err != nil { + res.Err = fmt.Errorf("build delegation tools: %w", err) + e.finishAudit(ctx, rec, "error", res, started, res.Err) + return res + } + // Run context: bound by MaxRuntime, 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 diff --git a/run/palette.go b/run/palette.go new file mode 100644 index 0000000..c95eb30 --- /dev/null +++ b/run/palette.go @@ -0,0 +1,78 @@ +package run + +import ( + "context" + "fmt" + + "gitea.stevedudenhoeffer.com/steve/majordomo/llm" + + "gitea.stevedudenhoeffer.com/steve/executus/tool" +) + +// addDelegationTools adds a delegation tool to the toolbox for each +// SkillPalette / SubAgentPalette entry, backed by the PaletteSource: +// +// - skill__ invokes the named saved skill with structured inputs. +// - agent__ invokes the named sub-agent with a prompt. +// +// Each delegated call runs as a CHILD of the current run (parentRunID = +// inv.RunID), inheriting the caller + channel. No-op when palette is nil or both +// palettes are empty — so an agent with no palette (or a host with no +// PaletteSource) simply has no delegation tools, exactly as before. +func addDelegationTools(box *llm.Toolbox, ra RunnableAgent, inv tool.Invocation, palette PaletteSource) error { + if palette == nil { + return nil + } + for _, name := range ra.SkillPalette { + name := name // capture + t := llm.DefineTool( + "skill__"+name, + fmt.Sprintf("Delegate the task to the %q skill. Provide its declared inputs.", name), + func(ctx context.Context, args skillDelegateArgs) (any, error) { + out, _, status, err := palette.InvokeSkill(ctx, inv.CallerID, inv.ChannelID, name, args.Inputs, inv.RunID) + if err != nil { + return nil, fmt.Errorf("skill %q failed: %w", name, err) + } + return delegationResult(name, "skill", out, status), nil + }, + ) + if err := box.Add(t); err != nil { + return fmt.Errorf("add skill__%s: %w", name, err) + } + } + for _, name := range ra.SubAgentPalette { + name := name // capture + t := llm.DefineTool( + "agent__"+name, + fmt.Sprintf("Delegate the task to the %q sub-agent with a natural-language prompt.", name), + func(ctx context.Context, args agentDelegateArgs) (any, error) { + out, _, status, err := palette.InvokeAgent(ctx, inv.CallerID, inv.ChannelID, name, args.Prompt, inv.RunID, "", "", nil, nil) + if err != nil { + return nil, fmt.Errorf("agent %q failed: %w", name, err) + } + return delegationResult(name, "agent", out, status), nil + }, + ) + if err := box.Add(t); err != nil { + return fmt.Errorf("add agent__%s: %w", name, err) + } + } + return nil +} + +// delegationResult surfaces a non-ok child status to the parent agent (so it can +// react to a timeout/cancel/budget stop) while still passing the partial output. +func delegationResult(name, kind, out, status string) string { + if status != "" && status != "ok" { + return fmt.Sprintf("[%s %q ended with status %q]\n%s", kind, name, status, out) + } + return out +} + +type skillDelegateArgs struct { + Inputs map[string]any `json:"inputs" description:"Inputs for the skill, matching its declared input schema."` +} + +type agentDelegateArgs struct { + Prompt string `json:"prompt" description:"The task/prompt to hand the sub-agent."` +} diff --git a/run/palette_test.go b/run/palette_test.go new file mode 100644 index 0000000..5eae9ed --- /dev/null +++ b/run/palette_test.go @@ -0,0 +1,101 @@ +package run_test + +import ( + "context" + "encoding/json" + "testing" + + "gitea.stevedudenhoeffer.com/steve/majordomo/llm" + "gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake" + + "gitea.stevedudenhoeffer.com/steve/executus/run" + "gitea.stevedudenhoeffer.com/steve/executus/tool" +) + +// recordingPalette captures the delegation call it received. +type recordingPalette struct { + gotName, gotCaller, gotParent string + gotInputs map[string]any +} + +func (p *recordingPalette) ResolveSkill(context.Context, string, string) (string, error) { + return "", nil +} +func (p *recordingPalette) InvokeSkill(_ context.Context, callerID, _, name string, inputs map[string]any, parentRunID string) (string, string, string, error) { + p.gotName, p.gotCaller, p.gotParent, p.gotInputs = name, callerID, parentRunID, inputs + return "the skill output", "child-run-1", "ok", nil +} +func (p *recordingPalette) ResolveAgent(context.Context, string, string) (string, error) { + return "", nil +} +func (p *recordingPalette) InvokeAgent(context.Context, string, string, string, string, string, string, string, []string, func(context.Context, string, string)) (string, string, string, error) { + return "", "", "ok", nil +} + +// TestPaletteDelegation: an agent with a SkillPalette gets a skill__ tool; +// the model calls it, the executor routes it through run.Ports.Palette as a +// child of the current run, and the result flows back into the loop. +func TestPaletteDelegation(t *testing.T) { + pal := &recordingPalette{} + + fp := fake.New("fake") + fp.Enqueue("m", + fake.ReplyWith(llm.Response{ToolCalls: []llm.ToolCall{{ + ID: "c1", + Name: "skill__helper", + Arguments: json.RawMessage(`{"inputs":{"q":"hi"}}`), + }}}), + fake.Reply("delegated and done"), + ) + m, err := fp.Model("m") + if err != nil { + t.Fatal(err) + } + + ex := run.New(run.Config{ + Registry: tool.NewRegistry(), + Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil }, + Ports: run.Ports{Palette: pal}, + }) + + res := ex.Run(context.Background(), + run.RunnableAgent{ID: "a1", Name: "boss", ModelTier: "m", SkillPalette: []string{"helper"}}, + tool.Invocation{RunID: "parent-run", CallerID: "caller-7", ChannelID: "chan"}, + "delegate please") + if res.Err != nil { + t.Fatalf("run error: %v", res.Err) + } + if res.Output != "delegated and done" { + t.Errorf("output = %q", res.Output) + } + if pal.gotName != "helper" { + t.Errorf("InvokeSkill name = %q, want helper", pal.gotName) + } + if pal.gotCaller != "caller-7" { + t.Errorf("InvokeSkill caller = %q, want caller-7", pal.gotCaller) + } + if pal.gotParent != "parent-run" { + t.Errorf("InvokeSkill parentRunID = %q, want parent-run (child of the current run)", pal.gotParent) + } + if pal.gotInputs["q"] != "hi" { + t.Errorf("InvokeSkill inputs = %+v, want q=hi", pal.gotInputs) + } +} + +// TestNoPaletteNoDelegationTools: nil PaletteSource → no delegation tools, run +// still works (the agent just has no skill__/agent__ tools). +func TestNoPaletteNoDelegationTools(t *testing.T) { + fp := fake.New("fake") + fp.Enqueue("m", fake.Reply("ok")) + m, _ := fp.Model("m") + ex := run.New(run.Config{ + Registry: tool.NewRegistry(), + Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil }, + }) + res := ex.Run(context.Background(), + run.RunnableAgent{Name: "x", ModelTier: "m", SkillPalette: []string{"helper"}}, + tool.Invocation{RunID: "r"}, "hi") + if res.Err != nil || res.Output != "ok" { + t.Fatalf("nil-palette run failed: %v / %q", res.Err, res.Output) + } +} -- 2.52.0 From 0c80679719d452afc8b5e82a1c92eb4f7e739bb4 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 27 Jun 2026 09:53:11 -0400 Subject: [PATCH 2/2] C0: address verified gadfly findings (trivial fixes) From the PR #8 review (all graded in the gadfly MCP): - skip empty palette names + dedupe by final tool name, instead of producing a "skill__" tool or an opaque box.Add duplicate error. - delegationResult: no trailing blank line when a non-ok child produced no output. - delegationErr: fold a child's partial output into the hard-failure error so it isn't silently dropped. Deferred to C0b (design-level, not trivial): route delegation through the tool.Registry gate/audit wrappers; expose the skill's real input schema to the LLM instead of a generic inputs map. typed-nil PaletteSource is left as a caller contract (the == nil guard catches the untyped-nil interface). Co-Authored-By: Claude Opus 4.8 (1M context) --- run/palette.go | 38 +++++++++++++++++++++++++++++++------- run/palette_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/run/palette.go b/run/palette.go index c95eb30..7c69cde 100644 --- a/run/palette.go +++ b/run/palette.go @@ -23,38 +23,49 @@ func addDelegationTools(box *llm.Toolbox, ra RunnableAgent, inv tool.Invocation, if palette == nil { return nil } + seen := map[string]bool{} // dedupe across both palettes by final tool name for _, name := range ra.SkillPalette { name := name // capture + toolName := "skill__" + name + if name == "" || seen[toolName] { // skip empty / duplicate palette entries + continue + } + seen[toolName] = true t := llm.DefineTool( - "skill__"+name, + toolName, fmt.Sprintf("Delegate the task to the %q skill. Provide its declared inputs.", name), func(ctx context.Context, args skillDelegateArgs) (any, error) { out, _, status, err := palette.InvokeSkill(ctx, inv.CallerID, inv.ChannelID, name, args.Inputs, inv.RunID) if err != nil { - return nil, fmt.Errorf("skill %q failed: %w", name, err) + return nil, delegationErr("skill", name, out, err) } return delegationResult(name, "skill", out, status), nil }, ) if err := box.Add(t); err != nil { - return fmt.Errorf("add skill__%s: %w", name, err) + return fmt.Errorf("add %s: %w", toolName, err) } } for _, name := range ra.SubAgentPalette { name := name // capture + toolName := "agent__" + name + if name == "" || seen[toolName] { + continue + } + seen[toolName] = true t := llm.DefineTool( - "agent__"+name, + toolName, fmt.Sprintf("Delegate the task to the %q sub-agent with a natural-language prompt.", name), func(ctx context.Context, args agentDelegateArgs) (any, error) { out, _, status, err := palette.InvokeAgent(ctx, inv.CallerID, inv.ChannelID, name, args.Prompt, inv.RunID, "", "", nil, nil) if err != nil { - return nil, fmt.Errorf("agent %q failed: %w", name, err) + return nil, delegationErr("agent", name, out, err) } return delegationResult(name, "agent", out, status), nil }, ) if err := box.Add(t); err != nil { - return fmt.Errorf("add agent__%s: %w", name, err) + return fmt.Errorf("add %s: %w", toolName, err) } } return nil @@ -64,11 +75,24 @@ func addDelegationTools(box *llm.Toolbox, ra RunnableAgent, inv tool.Invocation, // react to a timeout/cancel/budget stop) while still passing the partial output. func delegationResult(name, kind, out, status string) string { if status != "" && status != "ok" { - return fmt.Sprintf("[%s %q ended with status %q]\n%s", kind, name, status, out) + header := fmt.Sprintf("[%s %q ended with status %q]", kind, name, status) + if out == "" { // no trailing blank line when there's no body + return header + } + return header + "\n" + out } return out } +// delegationErr wraps a hard delegation failure, folding in any partial output +// the child produced before failing (so it isn't silently lost). +func delegationErr(kind, name, partial string, err error) error { + if partial != "" { + return fmt.Errorf("%s %q failed (partial output: %q): %w", kind, name, partial, err) + } + return fmt.Errorf("%s %q failed: %w", kind, name, err) +} + type skillDelegateArgs struct { Inputs map[string]any `json:"inputs" description:"Inputs for the skill, matching its declared input schema."` } diff --git a/run/palette_test.go b/run/palette_test.go index 5eae9ed..f48b6bf 100644 --- a/run/palette_test.go +++ b/run/palette_test.go @@ -99,3 +99,27 @@ func TestNoPaletteNoDelegationTools(t *testing.T) { t.Fatalf("nil-palette run failed: %v / %q", res.Err, res.Output) } } + +// TestDelegationDedupeAndEmptySkip: empty + duplicate palette names are skipped, +// not turned into "skill__"/duplicate tools that error at box.Add (gadfly C0). +func TestDelegationDedupeAndEmptySkip(t *testing.T) { + pal := &recordingPalette{} + fp := fake.New("fake") + fp.Enqueue("m", fake.Reply("ok")) + m, _ := fp.Model("m") + ex := run.New(run.Config{ + Registry: tool.NewRegistry(), + Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil }, + Ports: run.Ports{Palette: pal}, + }) + // "" (empty) and a duplicate "helper" must not break the build. + res := ex.Run(context.Background(), + run.RunnableAgent{Name: "x", ModelTier: "m", SkillPalette: []string{"helper", "", "helper"}}, + tool.Invocation{RunID: "r"}, "hi") + if res.Err != nil { + t.Fatalf("empty/duplicate palette names should be skipped, not error: %v", res.Err) + } + if res.Output != "ok" { + t.Fatalf("output = %q", res.Output) + } +} -- 2.52.0