Vectrum
Sign In

agent: list discovered skills in the cached system prompt

Options.Skills carries the skills discovered at startup. Their names and
descriptions are appended to the system prompt once, in New(), so the
cached prefix stays constant for the session; composing it in request()
would miss the cache on every turn. With no skills the prompt is
byte-identical to defaultSystem.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Jjonicommitted 1 parent 3cfb5f2commitab6fdfa

Showing 2 changed files with 60 additions and 0 deletions.

+22−0internal/agent/loop.goView File
@@ -9,6 +9,7 @@ import (
99
1010 "harness/internal/llm"
1111 "harness/internal/metrics"
12+ "harness/internal/skills"
1213 "harness/internal/tools"
1314 )
1415
@@ -38,6 +39,22 @@ do differently. Do not repeat a failing call unchanged.
3839
3940 Be concise. When the task is done, say what you changed and why, briefly.`
4041
42+ // skillListing is appended to the system prompt when skills were discovered.
43+ // It is constant for the session, so the cached prefix still matches request to
44+ // request: what breaks caching is content that varies per request, not content
45+ // that varies per session.
46+ func skillListing(found []skills.Skill) string {
47+ if len(found) == 0 {
48+ return ""
49+ }
50+ var b strings.Builder
51+ b.WriteString("\n\nSkills are instruction sets for specific kinds of work. Invoke one with\nthe skill tool when its description matches what you are doing — before\nstarting, not after. A user message beginning with /<name> is a request to\ninvoke that skill; everything after the name is its arguments.\n\nAvailable skills:")
52+ for _, s := range found {
53+ fmt.Fprintf(&b, "\n %s: %s", s.Name, s.Description)
54+ }
55+ return b.String()
56+ }
57+
4158 // Options configures an Agent.
4259 type Options struct {
4360 MaxTurns int
@@ -52,6 +69,10 @@ type Options struct {
5269 NoCache bool
5370 // Stream uses the SSE endpoint so output appears as it is produced.
5471 Stream bool
72+ // Skills are the instruction sets discovered at startup. Their names and
73+ // descriptions are appended to the system prompt; the bodies are loaded on
74+ // demand by the skill tool.
75+ Skills []skills.Skill
5576 }
5677
5778 // EventKind identifies what happened, for an observer to render.
@@ -116,6 +137,7 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log
116137 if opts.System == "" {
117138 opts.System = defaultSystem
118139 }
140+ opts.System += skillListing(opts.Skills)
119141 return &Agent{client: client, tools: reg, state: state, log: log, opts: opts}
120142 }
121143
+38−0internal/agent/loop_test.goView File
@@ -16,6 +16,7 @@ import (
1616
1717 "harness/internal/llm"
1818 "harness/internal/metrics"
19+ "harness/internal/skills"
1920 "harness/internal/tools"
2021 )
2122
@@ -489,3 +490,40 @@ func TestDeniedToolBecomesAToolError(t *testing.T) {
489490 t.Error("the file was read despite the denial")
490491 }
491492 }
493+
494+ func TestSystemPromptListsSkills(t *testing.T) {
495+ a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{
496+ Skills: []skills.Skill{{Name: "commit-helper", Description: "Use when writing commit messages"}},
497+ })
498+ sys := a.opts.System
499+ if !strings.Contains(sys, "commit-helper: Use when writing commit messages") {
500+ t.Errorf("system prompt does not list the skill:\n%s", sys)
501+ }
502+ if !strings.Contains(sys, "/<name>") {
503+ t.Error("system prompt does not explain what a leading slash means")
504+ }
505+ if !strings.HasPrefix(sys, defaultSystem) {
506+ t.Error("the listing replaced the default prompt instead of being appended to it")
507+ }
508+ }
509+
510+ func TestSystemPromptUnchangedWithoutSkills(t *testing.T) {
511+ a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{})
512+ if a.opts.System != defaultSystem {
513+ t.Errorf("system prompt differs from the default with no skills:\n%s", a.opts.System)
514+ }
515+ }
516+
517+ func TestSystemPromptIsStableAcrossRequests(t *testing.T) {
518+ // The listing sits in the cached prefix. If it varied per request, every
519+ // turn would miss the cache.
520+ a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{
521+ Skills: []skills.Skill{{Name: "a", Description: "one"}, {Name: "b", Description: "two"}},
522+ })
523+ a.state.Append(llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("hi")}})
524+ first := a.request().System[0].Text
525+ second := a.request().System[0].Text
526+ if first != second {
527+ t.Error("the system prompt changed between two requests in one session")
528+ }
529+ }