C0: wire Palette delegation into run.Executor (skill__/agent__ tools)
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__<name> tool (structured inputs) per SkillPalette entry and an agent__<name> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 ✓]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__<name> invokes the named saved skill with structured inputs.
|
||||
// - agent__<name> 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."`
|
||||
}
|
||||
@@ -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__<name> 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user