Skip to content

fix(agent): auto-inject SKILL.md into LLM context when user references a skill#1253

Closed
anugotta wants to merge 3 commits intosipeed:mainfrom
anugotta:fix/skill-context-auto-injection
Closed

fix(agent): auto-inject SKILL.md into LLM context when user references a skill#1253
anugotta wants to merge 3 commits intosipeed:mainfrom
anugotta:fix/skill-context-auto-injection

Conversation

@anugotta
Copy link
Copy Markdown

@anugotta anugotta commented Mar 8, 2026

📝 Description

Wire up the existing but unused LoadSkillsForContext() so that skill instructions (SKILL.md) are automatically injected into the LLM context when the user's message references an installed skill by name.

Problem: The system prompt lists installed skills in an XML <skills> summary and tells the LLM: "To use a skill, read its SKILL.md file using the read_file tool." However, smaller and local models (e.g. Ollama qwen3:14b) do not reliably decide to call read_file on their own. The LLM only sees the skill name and path — never the actual instructions. This makes skills effectively broken for local model setups.

Fix:

  • Added MatchSkillsInMessage() — case-insensitive whole-word matching of installed skill names against the user message
  • Added LoadSkillContext() — thin wrapper that calls the previously dead-code LoadSkillsForContext()
  • Extended BuildMessages() with a variadic skillContext parameter (backward-compatible — all existing callers unchanged)
  • Injected matched skill content as a dynamic (per-request, non-cached) content block in the system message
  • Wired matching and injection into runAgentLoop() before BuildMessages() is called

Note on Agent Refactor (#1216): This is a narrow fix that wires up existing dead code (LoadSkillsForContext in pkg/skills/loader.go), not a new Agent abstraction or semantic expansion. No new concepts are introduced.

🗣️ Type of Change

  • 🐞 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 📖 Documentation update
  • ⚡ Code refactoring (no functional changes, no api changes)

🤖 AI Code Generation

  • 🤖 Fully AI-generated (100% AI, 0% Human)
  • 🛠️ Mostly AI-generated (AI draft, Human verified/modified)
  • 👨‍💻 Mostly Human-written (Human lead, AI assisted or none)

🔗 Related Issue

Fixes #1249

📚 Technical Context (Skip for Docs)

  • Reference URL: SKILL.md not auto-injected into LLM context when skill is referenced #1249
  • Reasoning: LoadSkillsForContext() in pkg/skills/loader.go:177 was implemented and tested but never called from anywhere in the codebase. The context builder already generates a <skills> XML summary in the system prompt (Level 1), but the full SKILL.md content injection (Level 2) was dead code. This PR wires it up with message-level skill name matching so the LLM receives the actual skill instructions when a user references a skill.

🧪 Test Environment

  • Hardware: Raspberry Pi Zero 2 W
  • OS: Raspberry Pi OS Lite 64-bit
  • Model/Provider: stepfun/step-3.5-flash:free via OpenRouter
  • Channels: Telegram

📸 Evidence (Optional)

Click to view Logs/Screenshots

all modules verified Run generate... Run generate complete ok github.com/sipeed/picoclaw/cmd/picoclaw 0.829s ok github.com/sipeed/picoclaw/cmd/picoclaw/internal 1.381s ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent 2.824s ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth 2.033s ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron 3.392s ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway 4.196s ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate 4.679s ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard 5.232s ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills 5.271s ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/status 5.335s ok github.com/sipeed/picoclaw/cmd/picoclaw/internal/version 5.247s ok github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server 5.304s ok github.com/sipeed/picoclaw/pkg/agent 6.418s ok github.com/sipeed/picoclaw/pkg/auth 5.137s ok github.com/sipeed/picoclaw/pkg/bus 5.052s ok github.com/sipeed/picoclaw/pkg/channels 18.259s ok github.com/sipeed/picoclaw/pkg/channels/discord 5.131s ok github.com/sipeed/picoclaw/pkg/channels/feishu 5.128s ok github.com/sipeed/picoclaw/pkg/channels/irc 5.124s ok github.com/sipeed/picoclaw/pkg/channels/matrix 4.909s ok github.com/sipeed/picoclaw/pkg/channels/slack 4.625s ok github.com/sipeed/picoclaw/pkg/channels/telegram 5.353s ok github.com/sipeed/picoclaw/pkg/channels/wecom 5.489s ok github.com/sipeed/picoclaw/pkg/channels/whatsapp 5.222s ok github.com/sipeed/picoclaw/pkg/commands 5.148s ok github.com/sipeed/picoclaw/pkg/config 5.199s ok github.com/sipeed/picoclaw/pkg/cron 4.531s ok github.com/sipeed/picoclaw/pkg/heartbeat 3.301s ok github.com/sipeed/picoclaw/pkg/identity 3.634s ok github.com/sipeed/picoclaw/pkg/logger 4.057s ok github.com/sipeed/picoclaw/pkg/mcp 4.021s ok github.com/sipeed/picoclaw/pkg/media 4.736s ok github.com/sipeed/picoclaw/pkg/memory 9.930s ok github.com/sipeed/picoclaw/pkg/migrate 4.875s ok github.com/sipeed/picoclaw/pkg/migrate/internal 4.756s ok github.com/sipeed/picoclaw/pkg/migrate/sources/openclaw 5.229s ok github.com/sipeed/picoclaw/pkg/providers 20.150s ok github.com/sipeed/picoclaw/pkg/providers/anthropic 4.491s ok github.com/sipeed/picoclaw/pkg/providers/openai_compat 4.578s ok github.com/sipeed/picoclaw/pkg/routing 3.675s ok github.com/sipeed/picoclaw/pkg/session 3.655s ok github.com/sipeed/picoclaw/pkg/skills 9.172s ok github.com/sipeed/picoclaw/pkg/state 4.545s ok github.com/sipeed/picoclaw/pkg/tools 13.302s ok github.com/sipeed/picoclaw/pkg/utils 3.660s ok github.com/sipeed/picoclaw/pkg/voice 3.956s

☑️ Checklist

  • My code/docs follow the style of this project.
  • I have performed a self-review of my own changes.
  • I have updated the documentation accordingly.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 8, 2026

CLA assistant check
All committers have signed the CLA.

@sipeed-bot sipeed-bot bot added type: bug Something isn't working domain: agent go Pull requests that update go code labels Mar 8, 2026
@anugotta anugotta marked this pull request as ready for review March 8, 2026 21:12
@huaaudio huaaudio requested a review from Copilot March 9, 2026 01:05
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR wires the skills subsystem into the agent loop so that when a user references an installed skill by name, the corresponding SKILL.md instructions are automatically injected into the system prompt for that request (improving reliability on smaller/local models).

Changes:

  • Added per-message skill name matching and skill-context loading via ContextBuilder helpers.
  • Extended ContextBuilder.BuildMessages with an optional variadic skill context parameter and injected it into the system message.
  • Wired skill matching/injection into AgentLoop.runAgentLoop, and added unit tests for matching/loading/injection behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
pkg/agent/loop.go Matches skills from the user message and passes loaded SKILL.md context into message building.
pkg/agent/context.go Adds skill matching/loading helpers and injects active skill instructions into the system prompt via BuildMessages.
pkg/agent/context_skills_test.go New tests for skill name matching, skill context loading, and system prompt injection behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/agent/loop.go
Comment on lines +751 to 767
// Auto-inject SKILL.md content when the user references an installed skill.
var skillCtx string
if matched := agent.ContextBuilder.MatchSkillsInMessage(opts.UserMessage); len(matched) > 0 {
skillCtx = agent.ContextBuilder.LoadSkillContext(matched)
logger.DebugCF("agent", "Skills matched in user message",
map[string]any{"matched": matched, "context_len": len(skillCtx)})
}

messages := agent.ContextBuilder.BuildMessages(
history,
summary,
opts.UserMessage,
opts.Media,
opts.Channel,
opts.ChatID,
skillCtx,
)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skillCtx is injected into the initial BuildMessages call, but when a context-window error triggers compression retry, messages are rebuilt without passing skillCtx (later BuildMessages call in this function). That means the retry may lose the injected SKILL.md instructions even though the user message is still in history. Preserve or recompute skillCtx for the compressed retry path so behavior is consistent under large histories.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

@anugotta anugotta Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 62f556e

Comment thread pkg/agent/context.go
Comment on lines +724 to +727
allSkills := cb.skillsLoader.ListSkills()
if len(allSkills) == 0 {
return nil
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MatchSkillsInMessage calls skillsLoader.ListSkills() on every message, which performs directory scans/stat calls across workspace/global/builtin roots. If this runs on constrained devices or with many skills, it can add noticeable per-request I/O overhead. Consider caching the skill name->path index (e.g., alongside the existing system prompt cache baseline / skill mtimes) and only rebuilding it when skill files change.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListSkills() is lightweight for typical setups (a few stat calls across < 20 skills). The system prompt cache already tracks skill file mtimes for invalidation, so adding a separate skill name cache would duplicate that logic for minimal gain. Better take up if profiling shows it's a bottleneck?

Comment thread pkg/agent/context_skills_test.go
Comment thread pkg/agent/context.go Outdated
Comment on lines +759 to +763
func (cb *ContextBuilder) LoadSkillContext(skillNames []string) string {
if cb.skillsLoader == nil {
return ""
}
return cb.skillsLoader.LoadSkillsForContext(skillNames)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MatchSkillsInMessage returns SkillInfo.Name from SkillsLoader.ListSkills (which may come from SKILL.md frontmatter). SkillsLoader.LoadSkillsForContext/LoadSkill then tries to open //SKILL.md, so if the frontmatter name differs from the directory name the context load will silently fail (see pkg/skills/loader_test.go: TestListSkillsMetadataNameDedup where dir name != metadata name). Consider resolving matched skills to their SkillInfo.Path (or directory name derived from Path) when loading, or updating SkillsLoader.LoadSkill/LoadSkillsForContext to map metadata names to paths via ListSkills before reading files.

Suggested change
func (cb *ContextBuilder) LoadSkillContext(skillNames []string) string {
if cb.skillsLoader == nil {
return ""
}
return cb.skillsLoader.LoadSkillsForContext(skillNames)
//
// The provided skillNames may be either:
// - metadata names (from SKILL.md frontmatter), as returned by MatchSkillsInMessage, or
// - directory names corresponding to the skill paths.
//
// To ensure correct loading even when metadata names differ from directory names, this
// method resolves the inputs to directory names using the SkillsLoader's ListSkills data.
func (cb *ContextBuilder) LoadSkillContext(skillNames []string) string {
if cb.skillsLoader == nil || len(skillNames) == 0 {
return ""
}
allSkills := cb.skillsLoader.ListSkills()
if len(allSkills) == 0 {
return ""
}
// Build a case-insensitive mapping from both metadata names and directory
// names to the canonical directory name derived from SkillInfo.Path.
nameToDir := make(map[string]string, len(allSkills)*2)
for _, s := range allSkills {
dirName := filepath.Base(s.Path)
if dirName == "" {
continue
}
lowerDir := strings.ToLower(dirName)
nameToDir[lowerDir] = dirName
if s.Name != "" {
lowerMeta := strings.ToLower(s.Name)
nameToDir[lowerMeta] = dirName
}
}
resolved := make([]string, 0, len(skillNames))
for _, n := range skillNames {
if n == "" {
continue
}
key := strings.ToLower(n)
if dir, ok := nameToDir[key]; ok {
resolved = append(resolved, dir)
} else {
// Fall back to the original name to preserve existing behavior
// for callers that already pass directory names or rely on the
// previous direct pass-through behavior.
resolved = append(resolved, n)
}
}
if len(resolved) == 0 {
return ""
}
return cb.skillsLoader.LoadSkillsForContext(resolved)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

@anugotta anugotta Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 62f556e

@huaaudio
Copy link
Copy Markdown
Collaborator

huaaudio commented Mar 9, 2026

Hi @anugotta , thanks for the PR! One concern from my side: String-based matching is fragile. Simple skill names (pdf, weather) fire too easily, while users describing intent without exact names, e.g., "check my repo" vs github or "upload this file" vs cdn-uploader, won't match. This could lead to unnecessary token bloat or missed skill invocations. Have you considered an intent-based approach where the LLM itself determines whether a skill is relevant, rather than relying on literal string matching?

@anugotta
Copy link
Copy Markdown
Author

anugotta commented Mar 9, 2026

Hi @anugotta , thanks for the PR! One concern from my side: String-based matching is fragile. Simple skill names (pdf, weather) fire too easily, while users describing intent without exact names, e.g., "check my repo" vs github or "upload this file" vs cdn-uploader, won't match. This could lead to unnecessary token bloat or missed skill invocations. Have you considered an intent-based approach where the LLM itself determines whether a skill is relevant, rather than relying on literal string matching?

Thanks @huaaudio, that's a fair point about string matching limitations.

The reason I went with name matching is that this PR is specifically addressing the core problem in #1249 — models that don't reliably call tools on their own. The issue reporter notes that skills are "effectively broken for local models that don't independently decide to read SKILL.md files" and that "this impacts all Ollama-based setups." The system already tells the LLM to read_file the SKILL.md, but these models don't do it. I think an intent-based approach like a use_skill tool could have the same limitation since it still depends on the model deciding to make a tool call.

Name matching works as a deterministic safety net for these models — zero latency, no extra LLM calls, and LoadSkillsForContext() was already designed to take skill names (it was implemented and tested but never wired up).

That said, I completely agree that intent-based activation would be the better experience for more capable models. Would it make sense to keep this PR as the baseline fix for simpler models, and open a follow-up issue/PR for intent-based skill matching? The two approaches aren't mutually exclusive.

@huaaudio
Copy link
Copy Markdown
Collaborator

huaaudio commented Mar 9, 2026

Would it make sense to keep this PR as the baseline fix for simpler models, and open a follow-up issue/PR for intent-based skill matching? The two approaches aren't mutually exclusive.

Hi @anugotta , thanks for the reply! I was explicitly mentioning this because there is now an implementation of BM25 searching from recently merged PR #1243, which could be an alternative approach that gives us better results, and its code is made reusable in pkg/utils/bm25.go. Let me know what you think! I'm happy to help integrate this if you're interested. 🚀

@anugotta
Copy link
Copy Markdown
Author

Hi @anugotta , thanks for the reply! I was explicitly mentioning this because there is now an implementation of BM25 searching from recently merged PR #1243, which could be an alternative approach that gives us better results, and its code is made reusable in pkg/utils/bm25.go. Let me know what you think! I'm happy to help integrate this if you're interested. 🚀

Hi @huaaudio , Got it! Thanks for letting me know about PR #1243 . I will take a look and revert.

…s a skill

Wire up the existing but unused LoadSkillsForContext() so that skill
instructions are automatically injected into the system prompt when the
user's message references an installed skill by name.

Previously the LLM only saw a skills summary listing names and file
paths, with instructions to "read the SKILL.md using read_file". Smaller
and local models (e.g. Ollama) do not reliably decide to call read_file
on their own, leaving skills effectively broken.

Changes:
- Add MatchSkillsInMessage() for case-insensitive whole-word matching of
  skill names against the user message
- Add LoadSkillContext() wrapper to surface LoadSkillsForContext()
- Extend BuildMessages() with a variadic skillContext parameter
  (backward-compatible — all existing callers are unchanged)
- Inject matched skill content as a dynamic content block in the system
  message under "Active Skill Instructions"
- Wire the matching and injection into runAgentLoop() before
  BuildMessages() is called
- Add comprehensive tests for matching, boundary detection, context
  injection, and nil-safety

Fixes sipeed#1249

Made-with: Cursor
- Preserve skill context during compression retry so SKILL.md
  instructions are not lost when context window is exceeded
- Resolve frontmatter names to directory names in LoadSkillContext
  so skills whose metadata name differs from the directory name
  are loaded correctly
- Add test for metadata name != directory name case

Made-with: Cursor
@anugotta anugotta force-pushed the fix/skill-context-auto-injection branch from 62f556e to e20bfd6 Compare March 10, 2026 21:28
@anugotta
Copy link
Copy Markdown
Author

Hi @anugotta , thanks for the reply! I was explicitly mentioning this because there is now an implementation of BM25 searching from recently merged PR #1243, which could be an alternative approach that gives us better results, and its code is made reusable in pkg/utils/bm25.go. Let me know what you think! I'm happy to help integrate this if you're interested. 🚀

Hi @huaaudio I went through PR #1243 and BM25 implementation. Its cool and great help.
But I still feel low end models without reliable tool calls might still struggle with the BM25 approach.

Also read in PR #1243 desc:
"Cons: Increases response latency when a hidden tool is needed, as it requires an extra LLM round-trip (Search -> Read Results -> Call Tool)."

Not exactly sure if low end models would be smart enough to do this round-trip.

I feel we should go ahead with the name matching approach to address the core problem in #1249 — "models that don't reliably call tools on their own".

This is just my thought. If you think we should go ahead with the BM25 approach, I can look into that too. :)

@sipeed-bot
Copy link
Copy Markdown

sipeed-bot bot commented Mar 25, 2026

@anugotta Hi! This PR has had no activity for over 2 weeks, so I'm closing it for now to keep things organized. Feel free to reopen anytime if you'd like to continue.

@sipeed-bot sipeed-bot bot closed this Mar 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: agent go Pull requests that update go code type: bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SKILL.md not auto-injected into LLM context when skill is referenced

4 participants