Vectrum
Sign In

tools: a skill tool, auto-approved, with a slug enum

NewSkill builds a "skill" tool over discovered skills.Skill values,
returning nil when there are none so callers register nothing and the
prompt stays byte-identical without the feature. The tool description
lists available skills; the input schema's name property gets an enum
of the discovered slugs patched on via withEnum/patchedSchema, since
invopop/jsonschema can't reflect a list only known at startup.
Repeated invocations return the same body with no deduplication.

skill is added to approval's auto-approved read-only set: it only
reads a file from a directory found at startup, no more reach than
read_file already has.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Jjonicommitted 1 parent 1c9caeecommit3cfb5f2

Showing 4 changed files with 202 additions and 1 deletion.

+3−0internal/approval/policy.goView File
@@ -40,6 +40,9 @@ var readOnly = map[string]bool{
4040 "read_file": true,
4141 "glob": true,
4242 "grep": true,
43+ // skill reads one file from a directory found at startup, and cannot
44+ // reach anything read_file could not already reach.
45+ "skill": true,
4346 }
4447
4548 // Policy decides whether a tool call proceeds, remembering what it is told.
+1−1internal/approval/policy_test.goView File
@@ -15,7 +15,7 @@ func approve(t *testing.T, p *Policy, tool, input string) error {
1515 func TestReadOnlyToolsNeverPrompt(t *testing.T) {
1616 var asked int
1717 p := New(func(context.Context, Request) (Decision, error) { asked++; return Deny, nil })
18- for _, tool := range []string{"read_file", "glob", "grep"} {
18+ for _, tool := range []string{"read_file", "glob", "grep", "skill"} {
1919 if err := approve(t, p, tool, `{"path":"x"}`); err != nil {
2020 t.Errorf("%s was denied: %v", tool, err)
2121 }
+95−0internal/tools/skill.goaddedView File
@@ -0,0 +1,95 @@
1+ package tools
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "sort"
8+ "strings"
9+
10+ "harness/internal/skills"
11+ )
12+
13+ // SkillArgs is the skill input. The name is constrained to the discovered
14+ // slugs by an enum patched into the schema, so a hallucinated name is a schema
15+ // violation the model sees at once rather than a lookup failure it has to
16+ // reason about.
17+ type SkillArgs struct {
18+ Name string `json:"name" jsonschema:"description=Which skill to load"`
19+ Args string `json:"args,omitempty" jsonschema:"description=Arguments for the skill; whatever followed the skill name"`
20+ }
21+
22+ // NewSkill builds the skill tool over the skills discovered at startup. It
23+ // returns nil when there are none, so the caller registers nothing and the
24+ // prompt is byte-identical to a harness without the feature.
25+ func NewSkill(found []skills.Skill) Tool {
26+ if len(found) == 0 {
27+ return nil
28+ }
29+ byName := make(map[string]skills.Skill, len(found))
30+ names := make([]string, 0, len(found))
31+ var listing strings.Builder
32+ for _, s := range found {
33+ byName[s.Name] = s
34+ names = append(names, s.Name)
35+ fmt.Fprintf(&listing, "\n %s: %s", s.Name, s.Description)
36+ }
37+ sort.Strings(names)
38+
39+ desc := strings.TrimSpace(`
40+ Load a skill: a set of instructions for one kind of work. Invoke a skill when
41+ its description matches what you are about to do — before starting, not after.
42+ The instructions come back as the tool result; follow them.
43+
44+ Available skills:`) + listing.String()
45+
46+ t := New("skill", desc, func(_ context.Context, in SkillArgs) (string, error) {
47+ s, ok := byName[in.Name]
48+ if !ok {
49+ return "", fmt.Errorf("no skill named %q; available: %s", in.Name, strings.Join(names, ", "))
50+ }
51+ var b strings.Builder
52+ fmt.Fprintf(&b, "Skill: %s\nDirectory: %s\n", s.Name, s.Dir)
53+ b.WriteString("Relative paths below resolve against that directory.\n\n")
54+ b.WriteString(s.Body)
55+ if a := strings.TrimSpace(in.Args); a != "" {
56+ b.WriteString("\n\nARGUMENTS: " + a)
57+ }
58+ return b.String(), nil
59+ })
60+ return withEnum(t, "name", names)
61+ }
62+
63+ // withEnum patches an enum onto one string property of a reflected schema.
64+ // invopop/jsonschema derives the schema from struct tags, which cannot carry a
65+ // list only known at startup.
66+ func withEnum(t Tool, property string, values []string) Tool {
67+ var m map[string]any
68+ if err := json.Unmarshal(t.InputSchema(), &m); err != nil {
69+ return t
70+ }
71+ props, ok := m["properties"].(map[string]any)
72+ if !ok {
73+ return t
74+ }
75+ p, ok := props[property].(map[string]any)
76+ if !ok {
77+ return t
78+ }
79+ p["enum"] = values
80+ patched, err := json.Marshal(m)
81+ if err != nil {
82+ return t
83+ }
84+ return patchedSchema{Tool: t, schema: patched}
85+ }
86+
87+ // patchedSchema wraps a Tool to override only its input schema.
88+ type patchedSchema struct {
89+ Tool
90+ schema json.RawMessage
91+ }
92+
93+ // InputSchema returns the schema with the patched enum, overriding the
94+ // embedded Tool's reflected one.
95+ func (p patchedSchema) InputSchema() json.RawMessage { return p.schema }
+103−0internal/tools/skill_test.goaddedView File
@@ -0,0 +1,103 @@
1+ package tools
2+
3+ import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "harness/internal/skills"
9+ )
10+
11+ func testSkills() []skills.Skill {
12+ return []skills.Skill{
13+ {Name: "alpha", Description: "the first one", Body: "do the alpha thing", Dir: "/skills/alpha", Source: "personal"},
14+ {Name: "beta", Description: "the second one", Body: "do the beta thing", Dir: "/skills/beta", Source: "project"},
15+ }
16+ }
17+
18+ func TestSkillReturnsBodyWithDirectory(t *testing.T) {
19+ tool := NewSkill(testSkills())
20+ out, err := run(t, tool, map[string]any{"name": "alpha"})
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ for _, want := range []string{"Skill: alpha", "Directory: /skills/alpha", "do the alpha thing"} {
25+ if !strings.Contains(out, want) {
26+ t.Errorf("output missing %q:\n%s", want, out)
27+ }
28+ }
29+ }
30+
31+ func TestSkillAppendsArguments(t *testing.T) {
32+ tool := NewSkill(testSkills())
33+ out, err := run(t, tool, map[string]any{
34+ "name": "beta", "args": "fix the flaky test",
35+ })
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if !strings.Contains(out, "ARGUMENTS: fix the flaky test") {
40+ t.Errorf("arguments missing:\n%s", out)
41+ }
42+ }
43+
44+ func TestSkillOmitsArgumentsLineWhenEmpty(t *testing.T) {
45+ tool := NewSkill(testSkills())
46+ out, _ := run(t, tool, map[string]any{"name": "alpha"})
47+ if strings.Contains(out, "ARGUMENTS:") {
48+ t.Errorf("empty args produced an ARGUMENTS line:\n%s", out)
49+ }
50+ }
51+
52+ func TestSkillUnknownNameListsTheRealOnes(t *testing.T) {
53+ tool := NewSkill(testSkills())
54+ _, err := run(t, tool, map[string]any{"name": "nope"})
55+ if err == nil {
56+ t.Fatal("unknown skill was accepted")
57+ }
58+ if !strings.Contains(err.Error(), "alpha") || !strings.Contains(err.Error(), "beta") {
59+ t.Errorf("error does not list the available skills: %v", err)
60+ }
61+ }
62+
63+ func TestSkillSchemaEnumeratesSlugs(t *testing.T) {
64+ tool := NewSkill(testSkills())
65+ var schema struct {
66+ Properties struct {
67+ Name struct {
68+ Enum []string `json:"enum"`
69+ } `json:"name"`
70+ } `json:"properties"`
71+ }
72+ if err := json.Unmarshal(tool.InputSchema(), &schema); err != nil {
73+ t.Fatal(err)
74+ }
75+ if strings.Join(schema.Properties.Name.Enum, ",") != "alpha,beta" {
76+ t.Errorf("enum = %v, want [alpha beta]", schema.Properties.Name.Enum)
77+ }
78+ }
79+
80+ func TestSkillDescriptionListsSkills(t *testing.T) {
81+ tool := NewSkill(testSkills())
82+ d := tool.Description()
83+ if !strings.Contains(d, "alpha: the first one") || !strings.Contains(d, "beta: the second one") {
84+ t.Errorf("tool description does not list the skills:\n%s", d)
85+ }
86+ }
87+
88+ func TestNewSkillWithNoneIsNil(t *testing.T) {
89+ if NewSkill(nil) != nil {
90+ t.Error("NewSkill(nil) must return nil so the caller registers nothing")
91+ }
92+ }
93+
94+ func TestSkillRepeatInvocationReturnsBodyAgain(t *testing.T) {
95+ // Deduping would save tokens and cost the model the ability to re-read
96+ // instructions that compaction dropped.
97+ tool := NewSkill(testSkills())
98+ first, _ := run(t, tool, map[string]any{"name": "alpha"})
99+ second, _ := run(t, tool, map[string]any{"name": "alpha"})
100+ if first != second || first == "" {
101+ t.Error("the second invocation did not return the same body")
102+ }
103+ }