Skip to content

Commit f9e509a

Browse files
authored
Merge pull request #55 from neverdie0710/feat/progressive-skill-disclosure
feat: load skill bodies on demand
2 parents 876c001 + 181165c commit f9e509a

5 files changed

Lines changed: 273 additions & 55 deletions

File tree

internal/agent/loop.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ func NewAgentWithSkillsCfg(rc config.ResolvedAgent, prov provider.Provider, mb *
272272
// executor-pool failure would silently fall through to /bin/sh on the
273273
// host, defeating the security boundary the user asked for.
274274
skillDirs := loader.AllSkillDirs()
275+
tools.RegisterLoadSkill(registry, skillDirs)
275276
var sbCfg *tools.SandboxConfig
276277
if rc.Sandbox.Enabled {
277278
sbCfg = &tools.SandboxConfig{Enabled: true}
@@ -3083,6 +3084,7 @@ func (a *Agent) refreshSkillsFromStore(userID string) {
30833084
skills := loader.LoadSkills()
30843085
summary := loader.BuildSkillsSummary(skills)
30853086
a.ctxBuilder.SetSkillsSummary(summary)
3087+
tools.RegisterLoadSkill(a.registry, loader.AllSkillDirs())
30863088
// Per-turn fingerprint of the skill set the system prompt will
30873089
// ship. Lets us diff IM vs web for the same (agent, chatter) and
30883090
// confirm — or rule out — that agent-scope skills are reaching
@@ -3116,6 +3118,7 @@ func (a *Agent) ReloadWorkspaceFiles() {
31163118
}
31173119
skills := loader.LoadSkills()
31183120
skillsSummary := loader.BuildSkillsSummary(skills)
3121+
tools.RegisterLoadSkill(a.registry, loader.AllSkillDirs())
31193122
a.ctxBuilder = NewContextBuilder(a.homePath, a.memory, skillsSummary)
31203123
a.ctxBuilder.SetWorkspace(a.workspacePath)
31213124
a.ctxBuilder.SetPromptMode(a.promptMode)

internal/agent/skills.go

Lines changed: 71 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ import (
2020

2121
// Skill represents a discovered skill.
2222
type Skill struct {
23-
Name string // directory name
24-
Layer string // "agent", "user", "managed", "bundled", "extra"
25-
Content string // contents of SKILL.md (with {baseDir} replaced)
26-
BaseDir string // absolute path to the skill directory
27-
Description string // from frontmatter
28-
Metadata *SkillMetadata // parsed OpenClaw metadata
29-
Gated bool // true if gating requirements not met
30-
GateReason string // reason gating failed
23+
Name string // directory name
24+
Layer string // "agent", "user", "managed", "bundled", "extra"
25+
Content string // optional inline SKILL.md content for always-loaded skills
26+
BaseDir string // absolute path to the skill directory
27+
Description string // from frontmatter
28+
Metadata *SkillMetadata // parsed OpenClaw metadata
29+
Gated bool // true if gating requirements not met
30+
GateReason string // reason gating failed
3131
}
3232

3333
// SkillFrontmatter represents the YAML frontmatter of a SKILL.md file.
@@ -62,12 +62,12 @@ func (m *SkillMetadata) Meta() *OpenClawMeta {
6262

6363
// OpenClawMeta holds OpenClaw-specific metadata.
6464
type OpenClawMeta struct {
65-
Emoji string `json:"emoji"`
66-
Homepage string `json:"homepage"`
67-
Always bool `json:"always"`
68-
OS []string `json:"os"`
69-
Requires *SkillRequires `json:"requires"`
70-
PrimaryEnv string `json:"primaryEnv"`
65+
Emoji string `json:"emoji"`
66+
Homepage string `json:"homepage"`
67+
Always bool `json:"always"`
68+
OS []string `json:"os"`
69+
Requires *SkillRequires `json:"requires"`
70+
PrimaryEnv string `json:"primaryEnv"`
7171
// Env declares configurable environment variables this skill reads.
7272
// Surfaced to the admin UI so operators get labeled inputs (with
7373
// help text + secret masking) instead of having to grep main.py for
@@ -293,42 +293,66 @@ func (sl *SkillsLoader) LoadSkills() []Skill {
293293
}
294294

295295
// BuildSkillsSummary returns the skill section of the system prompt.
296-
// All discovered skills are inlined in full — operators install exactly
297-
// the skill set they want for their product agent (no marketplace, no
298-
// lazy load), so the LLM gets the complete SKILL.md content and can
299-
// invoke each skill directly via exec.
296+
// Skills use progressive disclosure by default: keep the prompt's always-on
297+
// context to the small name + description catalog, and let the model call
298+
// load_skill when it needs the full SKILL.md instructions. Explicit
299+
// always-load skills remain inline for compatibility with skills that must be
300+
// present before the first tool call.
300301
func (sl *SkillsLoader) BuildSkillsSummary(skills []Skill) string {
301302
if len(skills) == 0 {
302303
return ""
303304
}
304305
var sb strings.Builder
305306
sb.WriteString(skillsDirective)
306-
// Quick-reference catalog (name + first-line description) BEFORE
307-
// the inline `<skills>` block. The full SKILL.md content can be
308-
// 100+ KB total; a tiny name-only index up front lets the model
309-
// match "the user said 'use skill X'" against the catalog in one
310-
// pass instead of scanning the whole inline section. Catches the
311-
// "我手头没有 X" hallucination that showed up in group chats where
312-
// attention got diluted by the group_chat block — by the time the
313-
// model reached `<skill name="X">`, it had already decided X
314-
// didn't exist. Sorted (same order as the inline blocks below)
315-
// for diff-friendly logs.
316-
sb.WriteString("\n\n<skill_catalog>\nPre-installed skills available to this agent (full SKILL.md content follows below). Treat any user mention of one of these names as a request to invoke that skill via exec:\n")
307+
alwaysLoad := sl.alwaysLoadSet()
308+
inline := make([]Skill, 0)
309+
310+
sb.WriteString("\n\n<skill_catalog>\nPre-installed skills available to this agent. Treat any user mention of one of these names as a request to use that skill. Call `load_skill` with the skill name before following its detailed instructions:\n")
317311
for _, skill := range skills {
318312
desc := firstSentence(skill.Description)
319313
if desc == "" {
320314
desc = "(no description)"
321315
}
322316
fmt.Fprintf(&sb, "- %s — %s\n", skill.Name, desc)
317+
if alwaysLoad[skill.Name] || skillAlwaysLoads(skill) {
318+
inline = append(inline, skill)
319+
}
323320
}
324-
sb.WriteString("</skill_catalog>\n\n<skills>\n")
325-
for _, skill := range skills {
326-
fmt.Fprintf(&sb, "<skill name=%q layer=%q>\n%s\n</skill>\n", skill.Name, skill.Layer, skill.Content)
321+
sb.WriteString("</skill_catalog>")
322+
323+
if len(inline) > 0 {
324+
sb.WriteString("\n\n<always_loaded_skills>\n")
325+
for _, skill := range inline {
326+
content := skill.Content
327+
if content == "" {
328+
content = loadSkillContent(skill.BaseDir)
329+
}
330+
fmt.Fprintf(&sb, "<skill name=%q layer=%q>\n%s\n</skill>\n", skill.Name, skill.Layer, content)
331+
}
332+
sb.WriteString("</always_loaded_skills>")
327333
}
328-
sb.WriteString("</skills>")
329334
return sb.String()
330335
}
331336

337+
func (sl *SkillsLoader) alwaysLoadSet() map[string]bool {
338+
out := make(map[string]bool, len(sl.skillsCfg.AlwaysLoad)+len(sl.globalCfg.AlwaysLoad))
339+
for _, name := range sl.skillsCfg.AlwaysLoad {
340+
if name != "" {
341+
out[name] = true
342+
}
343+
}
344+
for _, name := range sl.globalCfg.AlwaysLoad {
345+
if name != "" {
346+
out[name] = true
347+
}
348+
}
349+
return out
350+
}
351+
352+
func skillAlwaysLoads(skill Skill) bool {
353+
return skill.Metadata != nil && skill.Metadata.Meta() != nil && skill.Metadata.Meta().Always
354+
}
355+
332356
// firstSentence returns the first sentence-ish chunk of s — used for
333357
// the skill-catalog one-liner. We bound the output to keep the catalog
334358
// scannable even when a skill's Description is a paragraph: cut at the
@@ -360,13 +384,13 @@ func firstSentence(s string) string {
360384
// rationalization ("this is one-shot, skip the ladder") that produced
361385
// reflexive `pip install` calls for tasks a published skill would handle.
362386
const skillsDirective = `<skill_usage_rules>
363-
The skills listed below are pre-installed for this agent. Each skill's full SKILL.md is included inline. To invoke one, run its main script via the exec tool and pass arguments on stdin as JSON; the SKILL.md describes args and return shape.
387+
The skills listed below are pre-installed for this agent. Only the catalog is always in context. Before using a skill, call the "load_skill" tool with its name to load the full SKILL.md instructions, then follow those instructions exactly. If an always-loaded skill is included inline below, you may use those inline instructions directly.
364388
365389
The sandbox image already has: python3 + pip, uv + uvx, node + npm + npx, the camoufox-cli anti-detect browser (run as ` + "`camoufox-cli open <url>`" + ` then ` + "`camoufox-cli snapshot -i`" + ` for refs; Camoufox/Firefox is pre-downloaded), git, curl, requests / pillow / beautifulsoup4 / lxml. DO NOT reinstall any of these — wasted tool calls and timeouts. If you see "command not found", check the spelling before reaching for npm/pip.
366390
367391
HTML preview: when the user asks to see / preview a web artifact, write the final HTML into the workspace and tell them the filename — the chat UI auto-renders .html files in a sandboxed iframe (CSS, JS, images, fonts work; cross-origin fetch from null origin does not). For source projects with a package.json (React, Vue, Vite, Next, …), run the project's build first (` + "`pnpm i && pnpm build`" + ` or the documented command) and point at the resulting ` + "`dist/index.html`" + ` (or equivalent). Live dev servers (` + "`vite dev`" + `, ` + "`next dev`" + `, ` + "`npm run dev`" + `) are NOT reachable from the browser — do not start them; they will hang and waste turns.
368392
369-
When the inline skills don't cover what the user asked for, follow this order BEFORE running any package install (pip / npm / apt / brew / cargo / gem / go install / …) via exec:
393+
When the listed skills don't cover what the user asked for, follow this order BEFORE running any package install (pip / npm / apt / brew / cargo / gem / go install / …) via exec:
370394
371395
1. If a "find-skills" skill is listed above, run it FIRST to search the open skill ecosystem. If a credible match exists, surface it and offer to install it instead of installing the package yourself.
372396
2. If no published skill fits, use "skill-creator" (if listed) to scaffold a new skill under skills/<name>/, then invoke it. Prefer this over inline scripts whenever the user might ask the same kind of thing again.
@@ -479,7 +503,9 @@ func userSkillsRootDir(userID string) string {
479503
}
480504

481505
// discoverSkillsEnhanced scans a directory for skill subdirectories with SKILL.md,
482-
// parses frontmatter, applies gating, and replaces {baseDir}.
506+
// parses frontmatter, and applies gating. It deliberately does not keep the
507+
// full SKILL.md body in memory for default skills; the model loads that body
508+
// on demand through load_skill.
483509
func discoverSkillsEnhanced(dir string, layer string) map[string]Skill {
484510
result := make(map[string]Skill)
485511

@@ -499,7 +525,6 @@ func discoverSkillsEnhanced(dir string, layer string) map[string]Skill {
499525
continue
500526
}
501527

502-
content := strings.TrimSpace(string(data))
503528
absDir, _ := filepath.Abs(skillDir)
504529

505530
// Parse frontmatter
@@ -513,9 +538,6 @@ func discoverSkillsEnhanced(dir string, layer string) map[string]Skill {
513538
}
514539
}
515540

516-
// Replace {baseDir} with the skill's absolute directory path
517-
content = strings.ReplaceAll(content, "{baseDir}", absDir)
518-
519541
// Apply gating
520542
gated, gateReason := checkGating(meta)
521543

@@ -528,7 +550,6 @@ func discoverSkillsEnhanced(dir string, layer string) map[string]Skill {
528550
result[name] = Skill{
529551
Name: name,
530552
Layer: layer,
531-
Content: content,
532553
BaseDir: absDir,
533554
Description: desc,
534555
Metadata: meta,
@@ -540,6 +561,15 @@ func discoverSkillsEnhanced(dir string, layer string) map[string]Skill {
540561
return result
541562
}
542563

564+
func loadSkillContent(skillDir string) string {
565+
data, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
566+
if err != nil {
567+
return ""
568+
}
569+
content := strings.TrimSpace(string(data))
570+
return strings.ReplaceAll(content, "{baseDir}", skillDir)
571+
}
572+
543573
func mapKeys(m map[string]map[string]config.SkillEntryCfg) []string {
544574
out := make([]string, 0, len(m))
545575
for k := range m {

internal/agent/skills_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package agent
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/fastclaw-ai/fastclaw/internal/config"
10+
)
11+
12+
func TestBuildSkillsSummaryUsesProgressiveDisclosureByDefault(t *testing.T) {
13+
t.Setenv("FASTCLAW_HOME", t.TempDir())
14+
home := t.TempDir()
15+
skillDir := filepath.Join(home, "skills", "chart-maker")
16+
if err := os.MkdirAll(skillDir, 0o755); err != nil {
17+
t.Fatal(err)
18+
}
19+
body := `---
20+
name: chart-maker
21+
description: Build charts from tabular data.
22+
---
23+
24+
SECRET_INLINE_BODY_SHOULD_NOT_APPEAR
25+
Run scripts/render.py with JSON input.`
26+
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(body), 0o644); err != nil {
27+
t.Fatal(err)
28+
}
29+
30+
loader := NewSkillsLoaderWithGlobal(home, t.TempDir(), "", config.SkillsConfig{}, config.SkillsCfg{})
31+
summary := loader.BuildSkillsSummary(loader.LoadSkills())
32+
33+
if !strings.Contains(summary, "chart-maker") {
34+
t.Fatalf("summary missing skill name:\n%s", summary)
35+
}
36+
if !strings.Contains(summary, "Build charts from tabular data") {
37+
t.Fatalf("summary missing skill description:\n%s", summary)
38+
}
39+
if strings.Contains(summary, "SECRET_INLINE_BODY_SHOULD_NOT_APPEAR") {
40+
t.Fatalf("summary leaked SKILL.md body:\n%s", summary)
41+
}
42+
if !strings.Contains(summary, "load_skill") {
43+
t.Fatalf("summary should tell the model to call load_skill:\n%s", summary)
44+
}
45+
}
46+
47+
func TestLoadSkillsDoesNotKeepBodyContentByDefault(t *testing.T) {
48+
t.Setenv("FASTCLAW_HOME", t.TempDir())
49+
home := t.TempDir()
50+
skillDir := filepath.Join(home, "skills", "chart-maker")
51+
if err := os.MkdirAll(skillDir, 0o755); err != nil {
52+
t.Fatal(err)
53+
}
54+
body := `---
55+
name: chart-maker
56+
description: Build charts from tabular data.
57+
---
58+
59+
BODY_SHOULD_STAY_ON_DISK_UNTIL_LOAD_SKILL`
60+
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(body), 0o644); err != nil {
61+
t.Fatal(err)
62+
}
63+
64+
loader := NewSkillsLoaderWithGlobal(home, t.TempDir(), "", config.SkillsConfig{}, config.SkillsCfg{})
65+
skills := loader.LoadSkills()
66+
67+
if len(skills) != 1 {
68+
t.Fatalf("skills len = %d, want 1", len(skills))
69+
}
70+
if skills[0].Content != "" {
71+
t.Fatalf("LoadSkills should not keep default skill body in memory, got:\n%s", skills[0].Content)
72+
}
73+
}
74+
75+
func TestBuildSkillsSummaryKeepsAlwaysLoadSkillsInline(t *testing.T) {
76+
t.Setenv("FASTCLAW_HOME", t.TempDir())
77+
home := t.TempDir()
78+
skillDir := filepath.Join(home, "skills", "always-inline")
79+
if err := os.MkdirAll(skillDir, 0o755); err != nil {
80+
t.Fatal(err)
81+
}
82+
body := `---
83+
name: always-inline
84+
description: Needs full instructions immediately.
85+
---
86+
87+
ALWAYS_LOAD_BODY_SHOULD_APPEAR`
88+
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(body), 0o644); err != nil {
89+
t.Fatal(err)
90+
}
91+
92+
loader := NewSkillsLoaderWithGlobal(
93+
home,
94+
t.TempDir(),
95+
"",
96+
config.SkillsConfig{AlwaysLoad: []string{"always-inline"}},
97+
config.SkillsCfg{},
98+
)
99+
summary := loader.BuildSkillsSummary(loader.LoadSkills())
100+
101+
if !strings.Contains(summary, "ALWAYS_LOAD_BODY_SHOULD_APPEAR") {
102+
t.Fatalf("summary should inline explicitly always-loaded skill:\n%s", summary)
103+
}
104+
}

internal/agent/tools/load_skill.go

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9+
"strings"
910
)
1011

1112
type loadSkillArgs struct {
1213
Name string `json:"name"`
1314
}
1415

1516
// RegisterLoadSkill registers the load_skill tool that reads full SKILL.md content.
16-
func RegisterLoadSkill(r *Registry, homeDir, agentDir, teamDir string) {
17+
func RegisterLoadSkill(r *Registry, skillDirs []string) {
1718
r.Register("load_skill", "Load the full content of a skill by name. Use this when you need detailed instructions for a specific skill.", map[string]interface{}{
1819
"type": "object",
1920
"properties": map[string]interface{}{
@@ -23,19 +24,10 @@ func RegisterLoadSkill(r *Registry, homeDir, agentDir, teamDir string) {
2324
},
2425
},
2526
"required": []string{"name"},
26-
}, makeLoadSkill(homeDir, agentDir, teamDir))
27+
}, makeLoadSkill(skillDirs))
2728
}
2829

29-
func makeLoadSkill(homeDir, agentDir, teamDir string) ToolFunc {
30-
// Directories to search in priority order (agent > team > global)
31-
searchDirs := []string{
32-
filepath.Join(agentDir, "skills"),
33-
}
34-
if teamDir != "" {
35-
searchDirs = append(searchDirs, filepath.Join(teamDir, "skills"))
36-
}
37-
searchDirs = append(searchDirs, filepath.Join(homeDir, "skills"))
38-
30+
func makeLoadSkill(skillDirs []string) ToolFunc {
3931
return func(ctx context.Context, rawArgs json.RawMessage) (string, error) {
4032
var args loadSkillArgs
4133
if err := json.Unmarshal(rawArgs, &args); err != nil {
@@ -47,11 +39,16 @@ func makeLoadSkill(homeDir, agentDir, teamDir string) ToolFunc {
4739
}
4840

4941
// Search through directories in priority order
50-
for _, dir := range searchDirs {
42+
for _, dir := range skillDirs {
43+
if dir == "" {
44+
continue
45+
}
5146
skillPath := filepath.Join(dir, args.Name, "SKILL.md")
5247
data, err := os.ReadFile(skillPath)
5348
if err == nil {
54-
return wrapSkillContentInternal(args.Name, string(data)), nil
49+
skillDir, _ := filepath.Abs(filepath.Join(dir, args.Name))
50+
content := strings.ReplaceAll(string(data), "{baseDir}", skillDir)
51+
return wrapSkillContentInternal(args.Name, content), nil
5552
}
5653
}
5754

0 commit comments

Comments
 (0)