From 8dca4ba4eb43edca562859ea30cd5e5cd3f15bda Mon Sep 17 00:00:00 2001 From: reasonix Date: Tue, 9 Jun 2026 06:32:50 -0700 Subject: [PATCH] feat(skill): add read_skill for loading inline skills in plan mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_skill is ReadOnly()=false because subagent skills can spawn writer tool calls, so plan mode (read-only) blocks ALL skill use — including inline skills, which only render a body and have no side effects. That made it impossible to consult a playbook while planning (#3491). Add read_skill: a read-only counterpart that renders an inline skill body and rejects subagent skills (pointing the model at run_skill). Being ReadOnly it stays available in plan mode. Added to SubagentMetaTools so spawned agents don't inherit it. Closes #3491 --- internal/agent/task.go | 1 + internal/boot/boot.go | 1 + internal/skill/tools.go | 52 ++++++++++++++++++++++++++++++++++++ internal/skill/tools_test.go | 27 +++++++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/internal/agent/task.go b/internal/agent/task.go index 1f2c06eb8..74373251c 100644 --- a/internal/agent/task.go +++ b/internal/agent/task.go @@ -23,6 +23,7 @@ If you need to ask for clarification, fail with a precise question instead of gu var subagentMetaTools = []string{ "task", "run_skill", + "read_skill", "install_skill", "install_source", "explore", diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 62ddec3c7..3116bfb09 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -491,6 +491,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { return &event.Profile{Model: model, Effort: effort} } reg.Add(skill.NewRunSkillTool(skillStore, skillRunner, skillProfile)) + reg.Add(skill.NewReadSkillTool(skillStore)) reg.Add(skill.NewInstallSkillTool(skillStore, nil)) reg.Add(installsource.NewTool(installsource.Options{ ProjectRoot: root, diff --git a/internal/skill/tools.go b/internal/skill/tools.go index 7ad0e1dd7..13fed81f2 100644 --- a/internal/skill/tools.go +++ b/internal/skill/tools.go @@ -128,6 +128,58 @@ func (t *runSkillTool) profileForSkill(sk Skill) *event.Profile { return &event.Profile{Model: model, Effort: effort} } +// readSkillTool loads an inline skill body into context without running anything. +type readSkillTool struct { + store *Store +} + +// NewReadSkillTool builds a read-only inline-skill loader. Unlike run_skill it +// stays available in plan mode, so a plan can consult inline playbooks. +func NewReadSkillTool(store *Store) tool.Tool { return &readSkillTool{store: store} } + +func (*readSkillTool) Name() string { return "read_skill" } + +// ReadOnly is true: read_skill only renders an inline skill body (no subagent, +// no side effects), so it is allowed in plan mode where run_skill is not. +func (*readSkillTool) ReadOnly() bool { return true } + +func (*readSkillTool) Description() string { + return "Load an inline playbook from the Skills index into your context WITHOUT running anything — the skill body returns as a tool result you read and follow. Read-only, so it works in plan mode (unlike run_skill). Pass `name` as the BARE identifier (e.g. 'commit'), NOT the `[🧬 subagent]` tag. Subagent-tagged skills are rejected: use run_skill (or the dedicated tool) for those, since they execute work." +} + +func (*readSkillTool) Schema() json.RawMessage { + return json.RawMessage(`{ +"type":"object", +"properties":{ + "name":{"type":"string","description":"Inline skill identifier as it appears in the pinned Skills index. Just the identifier, not the [🧬 subagent] tag."}, + "arguments":{"type":"string","description":"Optional free-form arguments, appended as an 'Arguments:' line; the skill's own instructions decide how to use them."} +}, +"required":["name"] +}`) +} + +func (t *readSkillTool) Execute(_ context.Context, args json.RawMessage) (string, error) { + var p struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } + if err := json.Unmarshal(args, &p); err != nil { + return "", fmt.Errorf("invalid args: %w", err) + } + name := cleanSkillName(p.Name) + if name == "" { + return "", fmt.Errorf("read_skill requires a 'name' argument (got %q, which is just a marker/tag)", p.Name) + } + sk, ok := t.store.Read(name) + if !ok { + return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store)) + } + if sk.RunAs == RunSubagent { + return "", fmt.Errorf("read_skill: skill %q is a subagent and must be executed, not read — use run_skill (or the dedicated %s tool)", name, name) + } + return renderInline(sk, strings.TrimSpace(p.Arguments)), nil +} + // --- dedicated subagent wrappers (explore / research / review / security_review) --- type subagentSkillTool struct { diff --git a/internal/skill/tools_test.go b/internal/skill/tools_test.go index af74007df..51e1f967b 100644 --- a/internal/skill/tools_test.go +++ b/internal/skill/tools_test.go @@ -209,3 +209,30 @@ func TestInstallSkill(t *testing.T) { t.Error("install_skill should require a description") } } + +func TestReadSkillLoadsInlineAndIsReadOnly(t *testing.T) { + home := t.TempDir() + writeSkill(t, home, ".reasonix/skills/note.md", "---\ndescription: take a note\n---\nDo the thing.") + tl := NewReadSkillTool(New(Options{HomeDir: home, DisableBuiltins: true})) + + if !tl.ReadOnly() { + t.Fatal("read_skill must be ReadOnly so it works in plan mode") + } + out, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"note","arguments":"with args"}`)) + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "Do the thing.") || !strings.Contains(out, "Arguments: with args") { + t.Errorf("inline body/args missing:\n%s", out) + } +} + +func TestReadSkillRejectsSubagent(t *testing.T) { + home := t.TempDir() + writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody") + tl := NewReadSkillTool(New(Options{HomeDir: home, DisableBuiltins: true})) + + if _, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"dig"}`)); err == nil || !strings.Contains(err.Error(), "run_skill") { + t.Fatalf("read_skill on a subagent skill should point to run_skill, got %v", err) + } +}