Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/agent/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions internal/boot/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions internal/skill/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions internal/skill/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading