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>
Showing 2 changed files with 60 additions and 0 deletions.
| @@ -9,6 +9,7 @@ import ( | ||
| 9 | 9 | |
| 10 | 10 | "harness/internal/llm" |
| 11 | 11 | "harness/internal/metrics" |
| 12 | + "harness/internal/skills" | |
| 12 | 13 | "harness/internal/tools" |
| 13 | 14 | ) |
| 14 | 15 | |
| @@ -38,6 +39,22 @@ do differently. Do not repeat a failing call unchanged. | ||
| 38 | 39 | |
| 39 | 40 | Be concise. When the task is done, say what you changed and why, briefly.` |
| 40 | 41 | |
| 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 | + | |
| 41 | 58 | // Options configures an Agent. |
| 42 | 59 | type Options struct { |
| 43 | 60 | MaxTurns int |
| @@ -52,6 +69,10 @@ type Options struct { | ||
| 52 | 69 | NoCache bool |
| 53 | 70 | // Stream uses the SSE endpoint so output appears as it is produced. |
| 54 | 71 | 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 | |
| 55 | 76 | } |
| 56 | 77 | |
| 57 | 78 | // 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 | ||
| 116 | 137 | if opts.System == "" { |
| 117 | 138 | opts.System = defaultSystem |
| 118 | 139 | } |
| 140 | + opts.System += skillListing(opts.Skills) | |
| 119 | 141 | return &Agent{client: client, tools: reg, state: state, log: log, opts: opts} |
| 120 | 142 | } |
| 121 | 143 | |
| @@ -16,6 +16,7 @@ import ( | ||
| 16 | 16 | |
| 17 | 17 | "harness/internal/llm" |
| 18 | 18 | "harness/internal/metrics" |
| 19 | + "harness/internal/skills" | |
| 19 | 20 | "harness/internal/tools" |
| 20 | 21 | ) |
| 21 | 22 | |
| @@ -489,3 +490,40 @@ func TestDeniedToolBecomesAToolError(t *testing.T) { | ||
| 489 | 490 | t.Error("the file was read despite the denial") |
| 490 | 491 | } |
| 491 | 492 | } |
| 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 | + } | |