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) + } +}